HarmonyOS 7 FloatViewController + Configuration:闪控窗字体缩放热更新的量测重排、按钮保底与焦点回放【鸿蒙心迹】
一、闪控窗没崩,但“发送”按钮消失了
问题出现在一条很普通的无障碍回归路径:QuickReplyPage 已经以 360×248 vp 的闪控窗打开,输入框里有“十分钟后到”,光标在第 18 位;测试同学不关闭窗口,直接把系统字体从 1.0 调到 1.75。标题和输入框跟着放大,底部“发送回复”被挤出可视区域,键盘焦点还在,却没有办法触达按钮。
首版只在闪控窗创建时读取一次尺寸,默认配置变化会触发完整重建。实际运行里,字体配置、组件量测和焦点恢复来自不同回调,先后顺序并不稳定。更糟的是,onConfigurationUpdate 连续到达两次,一次读到新 fontScale,一次仍拿到旧内容高度;页面先切成紧凑布局,又被第二次旧量测切回完整布局。这个问题没有异常堆栈,只有“按钮偶尔不见”,最后只能从时间线反推。

二、把字体变化当成一次布局事务
Demo 名为 FloatScaleLab,任务 ID 为 SCALE-0915,闪控窗固定 360×248 vp。字体缩放从 1.00 更新到 1.75,输入焦点为 reply-input,光标位置 18,布局代次 9。终态不是简单的“大字体模式”,而是 COMPACT_READY:标题收为一行,正文区可滚动,底部动作条固定保留 56 vp,按钮可见比例必须是 1.00。
状态机为 STABLE → RECONFIGURING → MEASURING → COMPACT_READY。配置事件只更新候选值,不直接改 UI;量测回调把内容高度写入同一候选槽;二者在 80 ms 静默窗口后共同提交。这样做看起来比 if (fontScale > 1.3) 麻烦,但它避免“新字体 + 旧高度”的半新快照。
三、ConfigurationHub 只产出不可变快照
第一段代码解决配置事件去抖和代次。Ability 收到配置更新后,把 fontScale 与事件序号交给 Hub;Hub 不保存页面引用,只在静默窗口结束时发出不可变快照。多次回调会取消旧定时器,只有最后一次获得提交资格。
interface ScaleSnapshot { generation: number; fontScale: number; receivedAt: number }
class ConfigurationHub {
private generation: number = 8
private timer: number = -1
private listener?: (snapshot: ScaleSnapshot) => void
onSnapshot(listener: (snapshot: ScaleSnapshot) => void): void {
this.listener = listener
}
accept(configuration: Configuration): void {
const candidate: ScaleSnapshot = {
generation: ++this.generation,
fontScale: configuration.fontSizeScale ?? 1,
receivedAt: Date.now()
}
if (this.timer >= 0) clearTimeout(this.timer)
this.timer = setTimeout(() => {
if (candidate.generation === this.generation) this.listener?.({ ...candidate })
this.timer = -1
}, 80)
}
dispose(): void {
if (this.timer >= 0) clearTimeout(this.timer)
this.listener = undefined
}
}
80 ms 不是系统保证,而是这个产品对连续配置抖动的经验预算,必须通过实机回归确认。dispose() 在 Ability 销毁时调用,避免定时器晚到后持有旧页面。generation 从 8 提升到 9,后续量测、焦点回放和日志都使用 9;任何仍带 8 的回调只能释放自己的资源,不能提交布局。
四、布局策略看“剩余空间”,不看设备型号
第二段代码根据窗口高度、字体缩放和内容实测高度选择布局。以前按手机、折叠屏写分支,在自由窗口和字体放大时很快失效。现在策略只关心可用几何:完整布局能放下就保持;放不下但保留动作条后仍有至少 96 vp 正文区,就切 COMPACT;再小则进入 SCROLL_SAFE,仍保证按钮区域不被内容抢占。
type LayoutMode = 'FULL' | 'COMPACT' | 'SCROLL_SAFE'
interface LayoutPolicy { mode: LayoutMode; bodyHeight: number; actionHeight: number }
function resolvePolicy(windowHeight: number, contentHeight: number,
fontScale: number): LayoutPolicy {
const safeTop = 12
const gap = fontScale >= 1.5 ? 8 : 12
const actionHeight = fontScale >= 1.5 ? 56 : 52
const available = windowHeight - safeTop - actionHeight - gap
if (contentHeight <= available) {
return { mode: 'FULL', bodyHeight: contentHeight, actionHeight }
}
if (available >= 96) {
return { mode: 'COMPACT', bodyHeight: available, actionHeight }
}
return { mode: 'SCROLL_SAFE', bodyHeight: Math.max(72, available), actionHeight }
}
任务终态里,内容自然高度是 312 vp,而窗口只有 248 vp;策略给动作条保留 56 vp,正文滚动保留 96 vp,剩余空间由标题、安全区和间距使用。按钮可见比例不是通过“看起来在屏幕内”判断,而是用 onVisibleAreaChange([1.0]) 记录,未达到 1.00 就不允许状态进入 COMPACT_READY。
五、重排之后再恢复焦点和光标
第三段代码处理最容易晚到的部分。配置快照到达时先记录当前输入框 ID、文本与光标;布局切换完成后的下一帧才请求焦点并恢复 caret。每一步都检查 generation,避免旧布局的回放抢回新页面焦点。
async function applyScale(snapshot: ScaleSnapshot): Promise<void> {
const continuity = {
focusId: this.focusedId,
text: this.replyText,
caret: this.replyController.caretOffset()
}
this.state = 'MEASURING'
const measured = await this.measureContent(snapshot.fontScale)
if (snapshot.generation !== this.activeGeneration) return
this.policy = resolvePolicy(248, measured.height, snapshot.fontScale)
this.replyText = continuity.text
await nextFrame()
if (snapshot.generation !== this.activeGeneration) return
if (continuity.focusId === 'reply-input') {
focusControl.requestFocus('reply-input')
this.replyController.caretPosition(continuity.caret)
}
this.state = 'COMPACT_READY'
}
不能在切换条件组件前调用 requestFocus(),因为旧 TextInput 即将销毁,新节点还不存在;也不能只恢复文本,不恢复 caret,否则用户下一次输入会跳到末尾。页面关闭时要递增 activeGeneration,并清空未完成的量测 Promise 订阅。即使系统回调无法真正取消,晚到结果也会因代次不匹配被丢弃。
六、从日志能看出哪一拍混了旧数据
工程目录为 pages/QuickReplyPage.ets、config/ConfigurationHub.ets、layout/ScaleLayoutPolicy.ets、state/ReplyContinuityStore.ets 和 model/ScaleMetrics.ets。HiLog 标签固定为 FloatScale。一次正确序列是:config generation=9 fontScale=1.75、measure contentHeight=312vp window=360x248vp、policy mode=COMPACT body=96 action=56、focus restored id=reply-input caret=18、visibleRatio=1.00 state=COMPACT_READY。

调试时我会故意保留窗口右侧模拟器,观察正文滚动与固定动作条;中间代码停在 generation 检查和下一帧焦点回放;底部日志按代次串起来。如果量测行是 generation=8,而策略行是 9,就算最终布局勉强可用也要判失败,因为下一次配置顺序一变,按钮仍可能消失。
七、最终状态要证明“看得见,也接得上”
在字体 1.75、窗口 360×248 vp 下,终态为 COMPACT_READY,正文区 96 vp,动作条 56 vp,按钮可见比例 1.00;输入文本保持“十分钟后到”,焦点仍为 reply-input,caret 为 18,重排耗时 126 ms。页面底部按钮“发送回复”可以直接点击,滚动只作用于正文区,不拖走动作条。

手机运行图还显示任务 SCALE-0915、代次 9、内容高度 312 vp 与“旧回放丢弃 1”。这一条丢弃不是错误,而是我们主动拒绝了 generation=8 的旧量测;真正的验收标准是旧回调没有改变终态。用户把字体调回 1.0 时会产生 generation=10,再按同一流程决定是否回到 FULL,而不是复用上一次模式。
八、闪控窗的小空间会放大每个生命周期问题
当前 Demo 只处理字体缩放,不把横竖屏、系统语言和深浅色模式塞进同一次事务;真实产品可以复用 Hub,但要为不同配置字段定义合并规则。极端字体比例下,固定 56 vp 动作条也可能不够,此时应允许按钮文字折行或改用图标加无障碍描述,不能无限压缩正文。
另外,onPageHide 不等于销毁。闪控窗暂时失焦时应保存连续性,不立即清空输入;只有窗口真正关闭、FloatViewController 被释放或业务会话切换时,才提升代次并解除订阅。这个问题修完后,我更在意的已经不是“字体能不能变大”,而是配置、量测、焦点三个时钟能否在一个提交点会合。按钮始终可达,只是这套时序收敛最终让用户看见的结果。
更多推荐





所有评论(0)