我准备连载一个原生鸿蒙开发系列,希望通过系列文章带你一同了解我是如何从0开发一款鸿蒙原生软件的,欢迎订阅关注。
鸿蒙布局修复的辗转实录
三个修复 commit,三轮被截图打脸,最后栽在键盘避让模式的默认值
终极答案只有一行设置,而它的默认值,恰好是个坑
编者按
开场白一个详情页的布局问题,我修了三轮。第一轮干掉顶部 140vp 的大空白,第二轮干掉“返回按钮被正文压住”,第三轮才挖到最深的雷:键盘弹起后,正文前 5-6 行永远滑不到。我把滚动容器从 Scroll 换成 List、把 padding 挪来挪去,全都没用。最后翻开 SDK 源码才发现,问题压根不在我的代码里——鸿蒙键盘避让模式的默认值是 OFFSET(布局整体上移),不是我以为的 RESIZE(内容区缩小)。
01
PART
背景:一个「看起来很正常」的详情页
BACKGROUND
我在做的 memos 鸿蒙版,详情页长这样:
┌─────────────────────┐
│ ← 详情 编辑 │ ← 标题栏(浮层)
├─────────────────────┤
│ 时间行 / 标题 │
│ 正文(可滚动) │ ← 内容区
│ ... │
├─────────────────────┤
│ 写评论… 发送 │ ← 评论栏
└─────────────────────┘
设计意图很简单:标题栏浮在顶部,正文独立滚动,评论栏钉在底部;键盘弹起来时,评论栏浮到键盘上方,正文该滚还是滚。
就这么个“简单”布局,花了我几个小时的时间,我打了三场战役,提交了三个修复 commit。每一场都以为“这次稳了”,结果都被下一张截图打脸。
02
PART
第一场战役:140vp 的顶部空白
CAMPAIGN Ⅰ
第一张截图:详情页顶部,从“详情”标题到“16 分钟前”之间,是一大块纯空白,目测占了屏幕快五分之一。一句话:这空白也太大了。
我打开代码一看,好家伙,同一个“让位”动作,我做了三遍:
56 + 40 + 56,加上 titlebar 自身的高度,顶部一共让出去 150vp 以上——不空白才怪。
「战术上的勤奋,掩盖战略上的懒惰」 ——每修一次键盘或标题栏问题,就往布局上叠一层 padding,从没想过这些 padding 会不会互相打架。
第一轮手术:
编译、部署、截图:空白没了,内容紧贴标题栏下方,漂亮。我以为这仗打完了。结果 7 分钟后,第二张截图来了。
03
PART
第二场战役:返回按钮被「国家」两个字压住
CAMPAIGN Ⅱ
第二条 memo 是一篇很短的:“国家高等教育平台 https://higher.smartedu.cn #resource”。键盘弹起来之后,「国家」两个字直接压在返回按钮「←」的圆角背景上。返回图标还是有遮挡。
我盯着截图看了半天,突然意识到一个之前完全忽略的事实:NavDestination 的 titlebar(包括系统自带的「←」返回按钮)是浮层,它不占 Column 的布局空间。
也就是说,Column 内容是从屏幕最顶上开始排的,titlebar 是「盖」在上面的。你必须在 Column 顶部留出足够的 padding,才能让内容避开浮层。
最坑的是:长 memo 一直表现正常,短 memo 一测就露馅。
原因很「巧合」:长 memo 内容超高,Scroll 自动向下滚动,顶部 padding 让位生效,第一行正文天然离 titlebar 远远的;短 memo 内容没超出屏幕,Scroll 不滚动,第一行正文就直接顶到 titlebar 浮层底下——于是和「←」重叠了。
长 memo 的「正常」,是滚动行为撞出来的巧合,不是布局本身正确。
第二轮手术:Column 重新加回 padding(top: 56) 让位 titlebar 浮层,Scroll 留 12vp 做视觉呼吸。两层各司其职,不冲突。
测试必须覆盖所有内容形态 ——长 memo、短 memo、纯文本、带代码块、带图片,一个都不能少。你信誓旦旦说「修好了」,很可能只是因为你测的那条 memo 恰好长。
04
PART
第三场战役:前 5-6 行永远滑不到
CAMPAIGN Ⅲ
第二场打完,我以为圆满了。结果又发现了新问题:“长 memo,写评论弹出键盘后,正文可以上下滑动,但无法滑动到最前面的部分(前 5-6 行高度)。”时间行、标题,那前 5-6 行的内容,永远滑不到。
我先怀疑 Scroll 自身的 padding 在键盘弹起(viewport 高度骤变)时导致 contentSize 计算错误,于是把 Scroll 的 padding 全部挪到内容 Column 上。编译、部署、测:无效。内容照样卡死。
我又想:Scroll 在高度骤变时测量不可靠,那就换成 List 吧——List 对 viewport 变化的测量应该更成熟。改造、部署、测:还是无效。offset 照样卡在 392px。两轮弯路让我警觉:这恐怕不是滚动容器的问题。
我做了一个之前没想到的测试:用 uitest uiInput fling 以 velocity=3000 的大力度向下甩。结果:内容纹丝不动。
Scroll/List 已经滚到物理最小值了,但内容顶部(时间行)还是不在可视区。这只有一种解释:内容顶部根本没在布局里,或者被推到了屏幕外。再 dump 布局对比,发现更诡异的:键盘弹起时,Column 的 padding(top:56) 像是「失效」了——Scroll 直接顶到 y=139(状态栏下方)。我一度以为是不是撞上了 ArkUI 的布局 bug。
后来我决定不猜了,直接翻 SDK 源码。在 @ohos.arkui.UIContext.d.ts 第 4389 行,一行注释让我瞳孔放大:
* Default mode: **KeyboardAvoidMode.OFFSET**.
鸿蒙键盘避让的默认模式是 OFFSET——布局整体上移,不是 RESIZE——内容区高度缩小。OFFSET 模式下,键盘弹起的瞬间,整个布局(包括滚动容器)整体上移了键盘高度。滚动容器顶部被推到屏幕上方——也就是 titlebar/状态栏覆盖的区域。于是:
数据没骗我,是我解读错了 ——dump 显示「padding 失效」,实际是布局整体上移抵消了 padding。
这就像「换游泳池解决不了不会游泳」:滚动容器(游泳池)没问题,是整个池子被抬到二楼去了。
第三轮手术,核心只有一行:
this.getUIContext()?.setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
RESIZE 模式下,键盘弹起时内容区高度缩小、顶部位置保持不变,offset=0 就是内容顶部,评论栏作为 Column 底部兄弟节点自然浮到键盘上方。
至于前面那个 List 改造——我保留了它当「冗余保险」(List 对 viewport 变化测量更成熟),但说实话,真正的修复就是那一行。部署、验证:时间行回来了,“50 分钟前”清晰地躺在标题栏下面,向上向下都能滚,短 memo 也没回归。三场战役,到此收官。
05
PART
复盘:五个教训
LESSONS
① 系统默认值,是最隐蔽的敌人。
我调了三个小时滚动容器,最后栽在一个「默认是 OFFSET」上。读 SDK 文档、翻源码,比反复试代码快得多。鸿蒙还很年轻,很多默认行为反直觉,别信「应该是什么样」,去查「实际是什么样」。
② 视觉正常 ≠ 所有状态正常。
长 memo 正常掩盖了短 memo 的问题;无键盘正常掩盖了键盘弹起的问题。以后布局改完,所有内容形态 + 键盘开关都要过一遍。
③ 换容器救不了「布局语义」问题。
Scroll 换 List,问题原样复现——因为根子在 NavDestination 的键盘避让行为,不在滚动容器。先定位「问题属于哪一层」,再动手。
④ 数据会骗人,但更多时候是我们解读错了。
dump 显示「padding 失效」,实际是布局整体上移抵消了 padding。多做一个「大力度 fling 触底测试」,比盯着数字猜强。
⑤ 反常的信号要抓住。
「内容滑到物理底了却还看不到开头」——这个信号足够反常,反常到应该立刻怀疑「底层行为」,而不是继续调参数。
///
END
写在最后
三场战役 · 三个 commit
三场战役,三个 commit(a43edfb、9afbe1b、733f659),从「砍 padding」到「让位浮层」再到「改键盘避让模式」,方案辗转反复,最后发现终极答案是一行设置——而这一行的默认值,恰好是个坑。
与诸君共勉。
高手和普通人的区别,往往不是谁更努力,而是谁先读到那行注释
我是 刘顾问,关注我,我们一同进步。
如果你觉得今天这篇有收获,欢迎点赞、在看、转发三连,我们下篇见
THANKS FOR READING