
去年我在给一台竖屏收银机做 Flutter 界面时遇到一个很拧巴的问题同一套登录页在手机上正正好换到 OpenHarmony 的工控大屏上就两边虚空一大片调成按平板适配后小屏手机又挤得按钮全叠在一起。那段时间我把 MediaQuery、LayoutBuilder、Flexible 换着用了好几轮最后还是回到 Flutter 自带的一个不起眼组件上——FractionallySizedBox 分数尺寸盒子。很多人一听到 Flutter for OpenHarmony第一反应是“哦就是让 Flutter 跑在开源系统上”但真正上手做布局时才会发现OpenHarmony 的设备形态比安卓碎片化更夸张手机、平板、手表、电视、车机、收银机、工业屏各种分辨率、各种宽高比甚至同一台设备还支持自由窗口。这时候如果还在用固定 px 或者 MediaQuery 手算百分比布局代码会变成一场灾难。FractionallySizedBox 干的事情很简单把父容器当前可用尺寸当成 100% 标尺用 0 到 1 之间的因子去分配子组件的宽和高。这篇文章我就把它的原理、实战场景、坑位和 OpenHarmony 上的适配差异一次讲清楚。提示这篇文章针对有一定 Flutter 基础、正在做 OpenHarmony 适配或想深入理解 Flutter 布局约束体系的开发者。即使你目前只做普通 Flutter 项目前面 4 个章节同样适用。1. 先别急着写代码OpenHarmony 的设备矩阵让像素思维失灵了1.1 Flutter for OpenHarmony 的现状先说清楚一个容易被误解的点Flutter for OpenHarmony 并不是把 Flutter 社区版原封不动搬过去而是 OpenHarmony 这边维护了一套基于 Flutter 稳定版的 fork包括 flutter_flutter、flutter_engine、flutter_packages 三个仓库。应用层写 Dart通过 ArkUI 的混流能力嵌入到 OpenHarmony 应用里也可以作为独立页面容器运行。我目前用的版本基于 Flutter 3.x日常写布局组件时绝大多数 Flutter 标准组件都是可用的FractionallySizedBox 就是其中之一。这带来一个直接后果社区里铺天盖地的“ArkTS 和 Flutter 谁更流行”的讨论其实很多时候是伪命题。ArkTS 是 ArkUI 声明式框架的应用开发语言Flutter 是一套跨端 UI 渲染方案两者既不是同一个赛道也谈不上互相替代。对一个团队来说更实际的问题是OpenHarmony 应用里有没有独立页面或模块适合用 Flutter 这种跨端方案来承载。如果有那 Flutter 的布局体系——包括今天要讲的这个组件——就得老老实实吃透。1.2 分数尺寸为什么是布局层的通用语为什么在 OpenHarmony 上布局要优先考虑分数尺寸我举一个真实的适配场景。我做过一个物流柜项目柜机屏幕有 7 英寸、10 英寸、15 英寸三种分辨率从 1024x600 到 1920x1080 不等。如果用固定宽度写按钮比如 240px在 1024 宽的屏上按钮占了接近四分之一在 1920 宽的屏上就显得很渺小点击区域也被压缩。用 MediaQuery 拿屏幕宽度算百分比也行但问题是一旦页面嵌套了侧边栏、导航栏、弹窗你真正能用的空间并不是“屏幕宽度”而是“父容器给你的约束空间”。MediaQuery 拿到的永远是整机屏幕参数跟当前这个组件的可用空间没有直接对应关系。FractionallySizedBox 的优势就在这里它不关心屏幕多大只关心“父约束的最大值”。父约束是 1000widthFactor 0.5 就是 500父约束是 300widthFactor 0.5 就是 150。不管你怎么嵌套、怎么切分窗口它的比例始终成立。在 OpenHarmony 这种窗口可自由缩放、设备形态繁多的生态里这种与“屏幕物理尺寸解耦”的布局方式远比像素思维可靠。2. 拆开 FractionallySizedBox一个因子如何重构父级约束2.1 参数与计算规则先看构造参数。FractionallySizedBox 的构造函数长这样const FractionallySizedBox({ super.key, this.alignment Alignment.center, this.widthFactor, this.heightFactor, super.child, }) : assert((widthFactor null) || (widthFactor 0.0)), assert((heightFactor null) || (heightFactor 0.0));参数只有三个alignment、widthFactor、heightFactor。widthFactor 表示宽度占父约束最大宽度的比例heightFactor 表示高度占父约束最大高度的比例两者都必须是 0 到正无穷的数值0 可以负数不行。比如父约束最大宽度是 400widthFactor: 0.5那么子组件布局宽度就是 200。关键问题来了这里说的“父约束最大宽度”到底是哪个值我见过非常多的人在这里理解偏差导致布局结果完全不符合预期。Flutter 的布局约束有两种典型形态紧约束tight constraints和松约束loose constraints。紧约束比如 SizedBox(width: 400, height: 600) 传下来的min 和 max 都是 400松约束比如 Center 传下来的min 是 0max 是 400。FractionallySizedBox 计算用的分母永远是 constraints.maxWidth / constraints.maxHeight也就是约束的最大值而不一定是屏幕宽度。举个例子ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500, maxHeight: 300), child: FractionallySizedBox( widthFactor: 0.5, heightFactor: 0.5, child: ColoredBox(color: Colors.blue), ), )这种情况下子组件会得到一个 250 x 150 的尺寸5000.52503000.5150而不是屏幕宽度的 50%。理解了这一点你就能解释为什么同一个组件在不同嵌套层级里表现会完全不同——不是它失效了而是它参照的“分母”变了。2.2 源码执行链路想真正搞懂这个组件的边界行为我建议直接跳进 Flutter framework 的源码。FractionallySizedBox 继承自 SingleChildRenderObjectWidget它对应的 RenderObject 是 RenderFractionallySizedOverflowBox类名里的 OverflowBox 是理解这个组件的关键——它允许子组件溢出。我简化一下 performLayout 里的核心逻辑void performLayout() { // 如果子组件不为 null 且 widthFactor 为 null // 就让孩子直接去占满父约束的最大宽度 double? newWidth (child ! null widthFactor null) ? constraints.maxWidth : null; double? newHeight (child ! null heightFactor null) ? constraints.maxHeight : null; if (child null) { size constraints.constrainSize(Size( widthFactor null ? 0.0 : constraints.maxWidth * widthFactor!, heightFactor null ? 0.0 : constraints.maxHeight * heightFactor!, )); } else { final BoxConstraints childConstraints BoxConstraints.tightFor( width: newWidth, height: newHeight, ); child!.layout(childConstraints); size constraints.constrainSize(Size( widthFactor null ? child!.size.width : constraints.maxWidth * widthFactor!, heightFactor null ? child!.size.height : constraints.maxHeight * heightFactor!, )); } }这段源码能解答一个非常重要的疑问如果 widthFactor 不写子组件会怎样答案是——它会被强制拉伸到父约束的最大宽度。很多人以为“不写比例就保持子组件原始尺寸”这是错的。只要你给了 childwidthFactor 为 null 时子组件宽度直接等于 constraints.maxWidthheightFactor 同理。换句话说想精准控制尺寸最好显式把两个因子都写出来让代码的意图一目了然。再看那个 XboxConstraints.tightFor传入的 childConstraints 是紧约束意味着子组件在布局时不拥有“讨价还价”的权利只能被动接受母公司算好的宽高。这也是为什么你在 FractionallySizedBox 里放一个 TextText 不会按自己的文本长度撑开宽度而是被强制变成 factor 对应的宽高内部再自动换行。2.3 alignment 不是你想的那个对齐FractionallySizedBox 的默认 alignment 是 Alignment.center0.0, 0.0但它的实际作用是基于子组件尺寸和自身尺寸的差值做偏移。你可以把它理解为子组件拿到 factor 对应尺寸后放在 FractionallySizedBox 的“画布”上然后用 alignment 决定具体放在画布的什么位置。这个机制和 Align 组件的 alignment 机制一模一样因为两者都继承自 RenderAligningShiftedBox。区别在于Align 本身不改变子组件的尺寸约束只是提供一个宽松的环境让子组件自己决定大小FractionallySizedBox 则是先通过 factor 约束死子组件的尺寸再在剩余空间里做对齐。所以当你写FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: 0.6, heightFactor: 0.3, child: coloredBox, )子组件宽度是父约束最大宽度的 60%然后被推到左侧边缘。这里的“剩余空间”是 40%不是整个画布理解了这个你写出来的布局就不会出现“对齐到奇怪位置”的问题。3. 四个能直接抄的实战场景登录页、播放器封面、仪表盘、骨架屏3.1 登录卡片宽度百分比 最大宽度上限最常见的需求是登录卡片宽度在不同屏幕上保持视觉和谐。手机屏幕窄卡片占宽一点平板和工控机屏幕宽卡片不能再按大屏宽度的 90% 走否则会宽到离谱。我的方案是 ConstrainedBox 限制最大宽度 FractionallySizedBox 限制最小占比Widget buildLoginCard() { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: FractionallySizedBox( widthFactor: 0.92, child: Card( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: loginFields, ), ), ), ), ), ); }解释一下效果屏幕宽度 360 时maxWidth 420 不生效卡片宽 3600.92 ≈ 331屏幕宽度 1080 时maxWidth 420 先截断卡片宽 4200.92 ≈ 386。这样手机不挤、大屏不散比单纯写死 360 或单纯写 90% 都灵活。这个组合我后来几乎用在了所有表单类页面适配成本极低。3.2 播放器封面标签栏上按 2:1 切分做媒体类页面时经常需要在详情卡片内部把区域按比例切开。我在一个 OpenHarmony 平板上做过音乐播放器唱片封面旁边有一个操作区操作区里按钮的行高我希望按“剩余高度的三分之一”来分配。用 Expanded 当然也能做但在某些场景里 Expanded 并不合适因为 Expanded 只能用在 Flex 的直接子组件上而 FractionallySizedBox 可以嵌套在任意容器里。实际代码大概是这样的Column( children: [ Expanded(flex: 2, child: coverArt), FractionallySizedBox( widthFactor: 1.0, heightFactor: 0.35, alignment: Alignment.center, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [prevButton, playButton, nextButton], ), ), ], )Column 传下来的垂直约束是 0 到剩余高度FractionallySizedBox 的 heightFactor 0.35 会取约束最大高度也就是剩余高度的 35% 作为操作区高度。无论上方封面占多少、屏幕多高操作区始终保持“剩余空间的三成半”。如果换成 Flexible flex 值就非得把操作区也变成 Column 的直接 Flex 子组件不可少了一点自由度。3.3 仪表盘九宫格在 GridView 单元格里做再分割GridView 本身会按 crossAxisCount 把单元格切成等宽但格子内部再切分时很多人就开始写死数值了。我做过一个能源监控面板每个格子里要放“时段曲线图 数值 单位”曲线图区希望占格子高度的 55%单位区占 15%。Grid 单元格的高度依赖屏幕宽度和 childAspectRatio是动态值直接写死 px 肯定不行。我的做法是在容器内部再套一层 Column曲线图区用 FractionallySizedBoxWidget cellWidget() { return Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FractionallySizedBox( widthFactor: 1.0, heightFactor: 0.55, child: curveChart, ), const SizedBox(height: 8), Text(本月能耗, style: ...), Expanded(child: Align(alignment: Alignment.bottomRight, child: valueText)), ], ), ); }这里曲线图区占格子内容区高度的 55%水平方向占满整个格子。因为把比例关系写进了组件树后面调整 GridView 的 childAspectRatio 时每个格子的内部结构依然保持原比例不会因为格子高度变了而出现图表挤压或溢出。这个思路在各类驾驶舱、监控屏上都能直接用。3.4 骨架屏与加载态给占位块一个比例框骨架屏是一个特别容易被忽略的场景。加载数据时页面通常要显示几个灰色的占位矩形这些矩形的高度最好能随屏幕尺寸变化——手机上一个标题条占 24px 就够平板上同样的视觉层级可能要占 40px。用固定高度在不同设备上要么太小要么太大。我在项目的加载态里统一用了 FractionallySizedBoxSizedBox.expand( child: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: 0.6, heightFactor: 0.12, child: Container( decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(8), ), ), ), )这样骨架条的高度自动跟随父容器高度走宽度也按比例收缩。把几个这样的组件叠在 Column 里就能拼出行列错落的加载骨架不需要在多个尺寸断点里维护一堆魔法数字。4. 和 Flexible、Expanded、AspectRatio、LayoutBuilder 怎么分家4.1 五个工具的约束消费对比Flutter 里能跟“按比例布局”沾边的组件不少很多人分不清什么时候该用哪个。我整理了一张对比表全部基于这几个组件在布局管线中的真实行为组件参照系约束处理方式典型使用范围FractionallySizedBox父约束的最大宽高用 tightFor 给子组件定死尺寸任意容器内按比例撑出子区域Flexible / ExpandedFlex 主轴剩余空间在 flex 布局里按 flex 系数分配剩余空间Row / Column / Flex 的直接子组件AspectRatio父约束的宽或高之一先尝试满足宽度再反推高度反过来也行固定宽高比区域比如视频、图片LayoutBuilder父约束本身把约束交给 builder 回调由开发者自行计算需要根据约束推导复杂布局逻辑Align父约束不改变约束只改变子组件在区域内的位置简单的居中、角落定位这个表解决了一个高频困惑为什么同样写“占 50%”Flexible(flex:1) 和 FractionallySizedBox(widthFactor:0.5) 结果可能不同因为 Flexible 的子组件直接放在 Row/Column 里它参与的是“弹性系数”分配把所有兄弟 Flex 子组件的 flex 值加总后按比例分剩余空间而 FractionallySizedBox 不管兄弟组件直接拿父约束最大值乘因子。两者参照系完全不同适用于完全不同的布局意图。4.2 我的选型经验看参照系而不是功能名我总结了一套快速的判断方法先问自己“我到底想让这个区域以什么为基准”。如果你的基准是“屏幕/卡片/某个容器的总宽高”选 FractionallySizedBox。它最适合的场景是弹窗内部切分、登录卡片宽度、骨架屏占位、以及任何“这个容器多大我就按比例占用多大”的需求。如果你的基准是“Row/Column 里和其他兄弟组件瓜分剩余空间”选 Flexible 或 Expanded。Expanded 等价于 Flexible(fit: FlexFit.tight)会让子组件强制占满分到的空间Flexible(fit: FlexFit.loose) 则允许子组件在分到的空间范围内保持自身尺寸。用来做底部操作栏、列表条目内部的动态伸缩区非常顺手。如果你的基准是“长度已知、宽高比例固定”选 AspectRatio。它内部本质是把约束转换成一个“先满足一个轴、再推另一个轴”的过程适合视频封面、图表容器、二维码这类有数学比例硬要求的场景。至于 LayoutBuilder它是兜底方案。当上面几个组件都不能表达你的意图比如你需要根据父容器宽度去做“超过 600 就 4 列否则 2 列”这种响应式决策就直接拿 constraints 在 builder 里算。它最灵活但也最需要自控力因为所有计算都得自己维护边界条件。顺带说一句很多团队天天在聊 provider、组件通信这些状态管理话题但布局基础组件才是真正写进每一页 UI 的东西。状态管理的选型错了可以重构布局约束理解错了每一次适配都在还债。先把这四个组件的使用边界划清楚比追任何热词都值。5. 最容易翻车的四个边界情况从报错到复现的完整记录5.1 ListView 里直接使用BoxConstraints forces an infinite width第一次踩这个坑是在一个横向滚动的列表里。我在 ListView 的 item 中直接写了 FractionallySizedBox(widthFactor: 0.5)结果运行到那页直接抛异常报错信息大意是BoxConstraints forces an infinite width。原因是水平方向的 ListView主轴方向传给 item 的约束是无界的unboundedconstraints.maxWidth 是 Infinity。FractionallySizedBox 拿 Infinity 乘 0.5算出来还是 Infinity于是 layout 直接炸裂。解决办法分几种。如果列表是横向的你必须给 item 一个确定宽度比如在外层套 SizedBox(width: 240)或者用 SliverGrid 之类的组件把 cross axis 约束转成有界如果是纵向 ListView则要避免在主轴方向垂直方向使用 heightFactor而宽度方向的 factor 仍然可以安全使用因为纵向列表中 cross axis 的宽度通常是有限的。记住一句话factor 只能用在有界约束的轴上。排查技巧把constraints.toString()打印出来看看 maxWidth / maxHeight 是不是 Infinity这是最快定位手段。5.2 因子全都不写它会把子组件塞满父约束有一个行为我在第 2 节源码里已经提到但因为它太反直觉值得单独拎出来讲给 FractionallySizedBox 传了 child但 widthFactor 和 heightFactor 都不写你会得到一个“铺满父约束”的子组件而不是“保持原始尺寸”的子组件。比如把 Image 放进一个 FractionallySizedBox 里FractionallySizedBox( child: Image.asset(assets/banner.png), )结果图片会被拉伸到父约束的最大宽高而不是保持图片本身的尺寸。这在大多数场景下不是你要的。如果你只是想让图片居中并保持固有尺寸用 Center 就够了想让图片按父容器百分比缩放才需要写 factor。写代码时建议把两个因子都显式声明避免读代码的人猜。我后来复盘发现这个坑最大的危害不是报错而是“表面看起来正常”——图片被拉大了但没到刺眼的程度视觉上觉得怪又说不上哪里怪。最后我用 Flutter Inspector 看布局才发现子组件被 tight 约束钉死了。5.3 Text 和分数尺寸的博弈第 3 节提到过FractionallySizedBox 会用 tightFor 给子组件定死宽高。这个特性对 Text 尤其危险。正常 Text 的宽度是根据字符串内容和字体决定的但如果 Text 被放进 FractionallySizedBox 并设置了 widthFactor 和 heightFactor它的布局宽度就被定死了。文本会在这个固定宽度内换行换行后如果文本实际需要的高度超过 factor 给的高度就会出现视觉上的“文字溢出”。两个常用解法。解法一FractionallySizedBox 里面套 Align 再放 Text。Align 会尽量收缩自身尺寸并允许 Text 保持在紧约束下的换行宽度内SizedBox.expand( child: FractionallySizedBox( widthFactor: 0.8, heightFactor: 0.4, child: Align( alignment: Alignment.center, child: Text(这是一段很长的说明文字, textAlign: TextAlign.center), ), ), )解法二如果只是想给文本区域一个相对占比不要把 Text 直接当 child而是先让 Container 等视觉组件占据 factor 空间再在 Container 内部处理文本布局。本质是FractionallySizedBox 管“区域比例”文本的排版交给区域内部的其他容器。5.4 嵌套 Row/Column 后的突然失效还有一次我踩的坑很隐蔽把 FractionallySizedBox 放进一个 Column然后发现 heightFactor 怎么调都不生效组件高度始终是子组件的高度。折腾半天才发现Column 传给非 Flex 子组件的约束在主轴方向是 0 到剩余高度看起来“有界”但剩余高度可能依赖整个 Column 的 mainAxisSize —— 默认是 max理论上应该没问题。真正的问题是 Column 外面还套了一层 SizedBox而 SizedBox 没有给高度方向约束导致整条约束链路变成无界。这种情况下factor 的计算分母是 InfinityFlutter 在 debug 模式下直接给出警告或断言失败release 模式下則表现为布局错乱。所以排查类似问题时我会从内向外重新推导一遍约束链路谁给 FractionallySizedBox 传的约束这个约束的 maxWidth / maxHeight 是否有限这也是我为什么建议在团队项目里给布局组件封装一层“约束校验工具”在 debug 模式下打印每个关键节点的 constraints。布局问题一大半是约束传递问题不是组件用法问题。6. Flutter for OpenHarmony 上的适配差异与真机验证6.1 纯 Dart 布局层一致差异在渲染后端前面一直在讲通用 Flutter 布局这一节回到标题里的 OpenHarmony。先说结论FractionallySizedBox 这类纯 Dart 层布局组件的计算逻辑在 OpenHarmony 的 Flutter 分支里和社区版完全一致因为布局由 framework 层 Dart 代码 RenderObject 完成不依赖平台。你不需要为这个组件单独写 OpenHarmony 适配代码。真正需要留意的差异在渲染后端。社区版 Flutter 新版逐步引入 Impeller 渲染引擎但在 OpenHarmony 的 Flutter 分支上目前主流还是基于 Skia 的渲染链路Impeller 在 OpenHarmony 上的落地程度因分支版本而异有的版本 Vulkan 后端还不完整。实际表现就是某些模糊滤镜、复杂 Shader、圆角裁剪在开 Impeller 的设备上可能出现渲染异常或闪烁。遇到这类问题先排除布局因素再尝试切换渲染后端对比别一上来就怀疑是 FractionallySizedBox 算错了。6.2 用调试工具和叠加层验证比例在 OpenHarmony 真机或模拟器上验证分数尺寸布局是否真正生效我有两个比较土但有效的手段。第一个是 Flutter Inspector。在 Debug 模式下运行应用点击组件树里的 RenderObject可以查看它的实际 size 和 constraints。你可以在代码里临时给关键的 FractionallySizedBox 加一个 Key然后在 Inspector 里精确搜索看实际尺寸是不是等于父约束最大值乘 factor。第二个是“比例标尺叠加层”。在调试阶段给页面最外层包一个 Stack叠加三个半透明的彩色区域分别占 25%、50%、75% 宽度再对比目标组件是否落在对应位置。这个方法不需要任何工具纯靠视觉校验我测试车机屏幕和收银机屏幕时经常用它比读日志直观得多。顺带提一下 OpenHarmony 的 XTS 认证。做设备兼容性适配时很多显示相关用例会对不同窗口比例下的 UI 完整性做检查。分数尺寸布局在这种场景下很占便宜因为它天然对窗口尺寸变化免疫——窗口从全屏切到 3:4组件还是按比例收缩不会出现固定像素偏移导致的截断。当然XTS 只是验收底线界面好不好看还得靠设计师把控比例层级。6.3 顺手解决flutter 新建项目后跑不起来的几个高频原因写 flutter/openharmony 相关话题几乎绕不开这个问题很多人按教程创建 Flutter 项目后在 OpenHarmony 设备上跑不起来。我遇到的高频原因有三个。第一依赖没拉干净。项目创建后默认会从 pub.dev 拉取插件网络不稳定时部分依赖缺失构建到一半直接失败。解决办法是优先配置国内的 pub 镜像源清理.dart_tool目录后重新flutter pub get。第二OpenHarmony 设备的调试协议是 hdc不是 adb。如果flutter devices里看不到你的设备检查 hdc 是否安装、设备是否授权以及 Flutter 工具链是否配置了 OpenHarmony SDK 的路径。第三应用没有正确声明 module 的 ability 信息。OpenHarmony 应用壳工程的 module.json5 里如果缺少对应的 ability 声明Flutter 引擎初始化会失败日志里经常出现flutter/runtime/dart_vm_initializer相关的错误。这类问题通常不是 Dart 代码的问题而是壳工程配置没对齐。定位时先看 OpenHarmony 侧日志再去看 Flutter 引擎日志不要一上来就怀疑是布局代码。最后回到那个收银机项目。我最终没有为了适配那三种屏幕写三套登录页而是把所有按比例的区域全部换成 FractionallySizedBox配合 ConstrainedBox 卡上限。后来项目又加入了横竖屏切换和自由窗口我没有再改过那页布局代码。布局这件事很多时候不是功能做不出来而是选错了参照系。希望大家看完这篇再遇到“按比例分配空间”的需求时能第一时间想到这个不起眼的分数尺寸盒子并且知道它的分母到底是谁。