仓库收货台有一种容易被忽略的要求:操作员拿着外接扫码设备,连续录入物流码时,输入光标不能突然跑掉。屏幕足够宽时,左边输入、右边核对;折叠或分屏后,界面收成一列。表面上只是布局变化,实际风险却在焦点和重复提交。本文使用一个构造的 ScanDesk 演示工程说明这件事。所有编号、日志和界面状态都是便于讲解的样例数据,并非已经在真机完成的测量或截图。核心目标不是开发扫码驱动,而是让键盘输入和外部设备模拟的字符流,在布局重排之后仍有明确的接收位置。## 一、把界面变化和业务提交拆开先给演示数据一个边界:会话号 SD-1028;待核对三件包裹;当前已经登记 PKG-4107 一件,页面显示“已登记 1/3”。演示屏幕为单栏,最终示例状态 FOCUS_READY,按钮叫“模拟登记”。这些数据固定写在文、图之中,方便检查状态对应关系。如果把宽屏和窄屏写成两个完整页面,切换布局往往等同于销毁旧输入框、创建新输入框。即使 @State scanText 保存了文字,也不能据此推断输入焦点一定还在。光标和组件状态不是同一层事物:前者属于当前 UI 节点,后者可以保存在组件字段中。业务层再设置一个约束:一次确认只能生成一条登记记录。布局变化不能触发提交,焦点恢复不能触发提交,真正提交发生在输入完成之后。扫码枪在这里被视为外接键盘,它不需要假装成某个不存在的 HarmonyOS 扫码接口。实际接入硬件前,要另做设备兼容性检查。同样不能用“我手动点回来就行”解决问题。用户双手忙于分拣时,多一次点击就可能漏掉下一个条码。设计上应当让光标恢复具备可观察的结果,而不是依赖一个没有被验证的自动获焦假设。## 二、媒体查询只负责决定展示宽度华为官方媒体查询文档给出了 @kit.ArkUI 中的 mediaquery、matchMediaSync 和 on('change'),并强调退出页面时取消监听。这里用 600vp 作为Demo 自定的交互分界线,不是系统规定的折叠屏阈值,也不代表设备铰链姿态。下面这段处理宽窄布局变化;它不提交条码,也不重建业务数据。tsimport { mediaquery } from '@kit.ArkUI';@Entry@Componentstruct ScanDeskPage { @State private isWide: boolean = false; @State private scanText: string = ''; @State private focusStatus: string = 'FOCUS_PENDING'; @State private lastCode: string = 'PKG-4107'; @State private scanCount: number = 1; private listener?: mediaquery.MediaQueryListener; private focusEpoch: number = 0; private active: boolean = false; private onWidthChanged = (r: mediaquery.MediaQueryResult): void => { this.isWide = r.matches; this.focusStatus = 'FOCUS_PENDING'; console.info(`[ScanDesk] SD-1028 layout=${this.isWide ? 'WIDE' : 'NARROW'}`); }; aboutToAppear(): void { this.active = true; this.listener = this.getUIContext() .getMediaQuery().matchMediaSync('(width >= 600vp)'); this.isWide = this.listener.matches; this.listener.on('change', this.onWidthChanged); } aboutToDisappear(): void { this.active = false; this.focusEpoch++; this.listener?.off('change', this.onWidthChanged); }````listener.matches` 是当前匹配结果,回调只更新 `isWide`,由 ArkUI 重绘相应布局。这里用 `active` 和 `focusEpoch` 为下一步异步焦点申请做失效保护。页面离开后必须解绑同一监听函数,否则再次进入时旧回调可能继续修改状态。注意 `MediaQuery` 感知的是显示区域条件,并不是“折叠屏打开或关闭”这一物理事件的同义词。分屏拖动窗口、旋转设备、平板自由窗口都可能触发;因此文中描述的是**重排场景**,不是铰链状态识别。不同设备上的 `600vp` 是否合适,应拿实机可用宽度测一遍再决定。## 三、焦点应当在新节点出现后申请在不同宽度分支里直接放两份 `TextInput`,容易出现相同 `id` 同时存在的错误。演示工程提取一个 `scanEditor()`,保证一个时刻只有一个 `scan_input`,它由页面持有的 `scanText` 数据驱动。即便节点重建,文本也不会作为某个临时局部变量丢失。接下来这段只解决焦点恢复。焦点申请发生在输入组件的 `onAppear` 之后,同时用令牌防止已经过期的异步任务覆盖新页面状态。tsprivate restoreScanFocus(): void { const epoch: number = ++this.focusEpoch; setTimeout(() => { if (!this.active || epoch !== this.focusEpoch) { return; } try { const ok: boolean = this.getUIContext() .getFocusController().requestFocus(‘scan_input’); this.focusStatus = ok ? ‘FOCUS_READY’ : ‘FOCUS_PENDING’; console.info([ScanDesk] SD-1028 focus=${this.focusStatus}); } catch (err) { this.focusStatus = ‘FOCUS_PENDING’; console.error(‘[ScanDesk] SD-1028 focus=FOCUS_PENDING’); } }, 0);}private commitScan(): void { const code: string = this.scanText.trim(); if (code.length === 0 || code === this.lastCode || this.scanCount >= 3) { return; } this.lastCode = code; this.scanCount++; this.scanText = ‘’; this.restoreScanFocus(); console.info([ScanDesk] SD-1028 accepted=${code} count=${this.scanCount}/3);}requestFocus('scan_input')` 并不是“强迫输入法弹出”的承诺。它返回布尔值,表示是否成功申请到目标组件焦点;组件未挂载、标识写错或组件不可获焦,都可能返回 `false`。华为焦点文档建议优先使用 `getUIContext().getFocusController()`,避免跨窗口调用全局焦点接口时找错实例。这里用一个异步任务安排焦点申请,但不能把 `setTimeout(0)` 当作每台设备渲染完成的严格证明。它降低了重排与申请挤在同一个调用栈的机会。更可靠的组合是让 `TextInput.onAppear` 触发它,再在多设备上验证。若出现持续 `FOCUS_PENDING`,应记录当前节点创建时序并进一步改造,而非无上限重试。`commitScan()` 同时演示了最小去重:空字符串不提交、与上一条相同的不重复登记,达到演示容量后三件停止。生产项目应当把重复判断扩展为会话内已登记编码集合,并在存储层做幂等;比较上一条并不能挡住隔了一条又扫同一码的情况。## 四、页面只维护一个输入入口第三段代码把布局决定与输入行为连起来。下面的页面主体是可整合的示意片段,`ScanDeskPage` 的前两段字段和方法与之共同组成核心逻辑;页面装饰属性可按项目设计稿补充。```ts@Builderprivate scanEditor() { Column({ space: 12 }) { Text('会话 SD-1028') TextInput({ text: this.scanText, placeholder: '输入物流码' }) .id('scan_input') .onChange((value: string) => { this.scanText = value; }) .onAppear(() => { this.restoreScanFocus(); }) .onSubmit(() => { this.commitScan(); }) Button('模拟登记') .onClick(() => { this.commitScan(); }) Text(`焦点状态 ${this.focusStatus}`) }.width('100%')}build() { Column({ space: 18 }) { Text('ScanDesk · 收货核对').fontSize(23) Text(this.isWide ? '双栏工作区' : '单栏工作区') if (this.isWide) { Row({ space: 20 }) { this.scanEditor() Text(`已登记 ${this.scanCount}/3 · ${this.lastCode}`) } } else { this.scanEditor() Text(`已登记 ${this.scanCount}/3 · ${this.lastCode}`) } }.width('100%').padding(20)}@Builder 提供公共的编辑器描述,但分支仍会更换父布局,不能把它误解为“永远不销毁节点”。稳定的是业务状态持有位置和组件标识。TextInput的onChange 是文字更新入口,onSubmit与按钮共同调用同一个commitScan,避免两套校验逻辑发生分歧。示例没有在宽屏下复制第二个 scan_input;否则 requestFocus的目标可能不确定。布局上大屏增加右侧核对摘要,小屏把摘要移到编辑区下方。无需为视觉排列差异写两份收货逻辑,这正是将数据与布局分离的收益。![](https://i-blog.csdnimg.cn/direct/ef1ec292ed1c4199935317277ef089b8.png)## 五、先设计调试路径,再谈“恢复成功”本篇不把生成的 DevEco 演示图视为真实日志。建议的实际验证顺序是:启动窄屏输入一段未提交文本;扩大窗口到超过 Demo 阈值;缩回窄屏;直接通过外接键盘输入下一个字符,观察是否落在scan_input。最后分别通过回车和按钮提交,检查是否发生两次入库。演示日志期望看到 SD-1028 layout=NARROW,接着是 SD-1028 focus=FOCUS_READY;页面样例中保留“单栏工作区”“已登记 1/3”“PKG-4107”。这不是实测通过的证明,而是期望状态契约。如果日志仍为 FOCUS_PENDING,就先查 id 是否存在、组件是否可获焦、onAppear是否调用、是否在短时间里再次切换布局。日志记录应当克制。用于诊断焦点恢复时可以记录布局名、会话 ID 和焦点状态;不要把每次扫描的完整物流码都发到生产日志,尤其当编码能关联收件信息。示例展示的是虚构PKG-4107,真实系统要考虑脱敏、日志保留周期及故障反馈权限。![](https://i-blog.csdnimg.cn/direct/0a463403b81d4efc97c64a760c988fa0.png)## 六、把能力边界写进验收标准可以做出的结论是:将文本状态保存在页面层、通过媒体查询区分布局、通过已挂载输入节点申请焦点、离开时解除监听,这四件事能构成一个可诊断的适配方案。它不等于任何折叠设备、任何软键盘及任何扫码枪上都已被证明工作正常。建议把验收写成行为而不是截图:连续缩放十次后,仍只有一个 scan_input`;输入中的草稿没有丢失;提交动作不会由重排额外触发;页面退出后不再有媒体查询回调;焦点失败能被识别并给出人工点击入口。扫码硬件的接入和自动换行规则要单独测试。还有一个细节:如果业务页含弹窗、输入法浮层或屏幕阅读器,自动抢回焦点可能损害可访问性。这时应加入“用户是否仍在编辑”的意图判断,弹窗展示期间暂停自动聚焦。焦点管理的目标不是永远夺回控制权,而是让操作流不被无关的布局变化打断。参考资料(华为官方): 媒体查询:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/arkts-layout-development-media-query-V5;焦点控制:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ts-universal-attributes-focus-V14;焦点时序问题:https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-1161。注:图片为说明工程方案而生成的演示界面,数据与日志均为构造样例;代码需结合实际 SDK、目标设备完成编译及真机检验。

Logo

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

更多推荐