1. 从一次布局返工说起:这几个知识点为什么必须放一起讲
前几天帮一位刚转 Flutter 的朋友看代码,需求很简单:一张商品卡片,右上角贴一个"限时"角标,点卡片进详情页,详情页底部有个悬浮的购买按钮。他写了两个小时,角标位置怎么调都不对,一会儿跑到卡片外面,一会儿被裁掉一半;进了详情页想回传一个"已购买"状态,又不知道pop的返回值怎么接。最后他把代码发过来,我扫了一眼就笑了——Stack的alignment没设,Positioned只写了top和right却没给子组件明确尺寸,返回值的接收又写在了await之后忘了判空。
这四个东西——Stack、Positioned、屏幕相关尺寸、Navigator路由跳转——在我眼里从来不是四个独立的知识点,而是"一个完整页面从搭骨架到跳出去"的最小闭环。Stack解决的是平面上的层叠定位,屏幕尺寸解决的是"层叠的坐标系到底有多大、可用区域在哪",Navigator解决的是页面与页面之间的切换和数据回流。你把任何一个拆开单独看,都能看懂;但真正写业务代码的时候,它们一定是同时出现的。
这篇内容适合谁看?如果你已经能写出一个能跑的Scaffold页面,但对"为什么我的Positioned不生效""MediaQuery那一堆padding到底该用哪个""push之后setState报红"这类问题还在靠试错解决,那这篇就是写给你的。我会把每个参数的约束传播逻辑、每种写法的取舍理由、以及我这些年踩过的具体坑都摊开讲,代码可以直接抄,但更希望你看完知道为什么这么抄。
下面所有的实操都基于当前稳定版 Flutter SDK,Dart 3 语法,Material 3 默认开启。如果你还在 2.x 时代,部分 API 名称会略有差异,但设计思路完全一致。
2. Stack 与 Positioned:层叠布局的约束传播逻辑
2.1 Stack 自己有多大?三种 fit 决定了子组件能拿到什么约束
很多人用Stack的第一反应是"它跟Container一样,我给它多大它就多大"。这是个危险的误解。Stack的尺寸计算有一套明确的顺序:先测量所有非Positioned的子组件(也就是直接挂在Stack下面的普通 child),在这个过程中根据fit参数决定传给它们的约束,然后再根据这些子组件的尺寸算出Stack自身的大小,最后才去布局Positioned的子组件。
fit有三个取值,行为差别很大:
| fit 取值 | 传给非定位子组件的约束 | 典型结果 |
|---|---|---|
StackFit.loose(默认) | 宽松约束,最小 0、最大为父级给的上限 | 子组件按自身尺寸显示,Stack取最大子组件尺寸 |
StackFit.expand | 紧约束,宽高都强制等于父级上限 | 子组件被拉满,Stack撑满父级 |
StackFit.passthrough | 原样透传父级给Stack的约束 | 子组件自己决定是否撑满,行为最"透明" |
我实测下来最常用的是expand。原因很实在:当你把Stack当作一个页面的根容器,用来做背景图加前景内容的叠加时,默认的loose会让Stack的尺寸取决于最大的那个子组件,如果前景内容比背景图小,整个Stack就会缩水,背景图也跟着变形。加上fit: StackFit.expand之后,所有非定位子组件都会被强制拉满,行为就符合直觉了。
还有一个容易被忽略的参数是clipBehavior。默认值是Clip.hardEdge,意思是超出Stack边界的部分会被硬裁掉。这解释了一个高频疑惑:为什么我Positioned写了top: -10,子组件就消失了?不是没生效,是被裁了。想让它露出来,把clipBehavior改成Clip.none即可。但要注意,关掉裁剪之后超出部分依然会参与命中测试,如果超出区域盖住了别的可点击控件,就会产生"点了没反应"或者"点错东西"的怪现象。我的习惯是:只在确实需要溢出显示(比如气泡尖角、小红点探出边缘)时才关裁剪,并且给溢出的那部分留出足够的父级 padding 空间。
alignment参数管的是非定位子组件的对齐方式,默认AlignmentDirectional.topStart。它不影响Positioned的子组件——这一点一定要记牢,Positioned的子组件位置完全由它自己的四个边距加宽高决定,alignment对它无效。我见过有人给Stack设了alignment: Alignment.center,然后困惑为什么他的Positioned(top: 0)还是在顶部,就是这个原因。
2.2 Positioned 的六种边距组合,写错一个就白干
Positioned的本质是"给子组件换一套约束"。它只对Stack的直接子组件有效,套在别的容器里会直接抛异常。它的六个参数——left、top、right、bottom、width、height——组合起来有明确的优先级规则,我把它整理成下面这张表,建议直接存下来:
| 写法 | 效果 | 说明 |
|---|---|---|
只写left+top | 子组件按自身尺寸定位到左上角偏移处 | 最常见,但子组件必须自己有确定尺寸 |
写left+right | 宽度被拉伸为两者之间的间距 | 高度仍需子组件自己决定 |
写left+right+width | width生效,right被忽略 | 三个一起写会冲突,框架取width |
| 四个都写 | 宽高都被拉伸,子组件被强行撑满 | 内部若是有固定尺寸的图片会被拉伸变形 |
| 一个都不写 | 等同于非定位子组件,走alignment | 经常被误用成"居中",其实不居中 |
Positioned.fill | 四边全 0,等价于四个都写 0 | 做遮罩层最省事 |
这里有个真实的坑值得说:left和right同时写的时候,如果子组件本身是Text这种有内在宽度的东西,Text会被放进一个宽度确定的盒子里,然后按需换行。我做过一个"文字压在图左侧、右侧留 24 间距"的效果,就是靠left: 16, right: 24实现的,比外面套Padding再套Align简洁得多。但如果你写的是Row加Expanded,那就没必要再用Positioned拉伸,约束会打架,最后计算结果可能出乎意料。
Positioned还有三个构造器变体,日常用得上的是Positioned.fill和Positioned.fromRect。前者做半透明遮罩、加载蒙层几乎是标配;后者接受一个Rect,配合LayoutBuilder或者手势拖动做自由定位特别顺手。Positioned.fromRelativeRect接受的是相对比例,如果你的定位需要按父容器百分比计算(比如"距离顶部 30%"),用它可以少写一次尺寸换算。
2.3 三个高频场景:角标、遮罩、悬浮按钮
先说角标。这是Stack最经典的应用,但很多人写出来位置总是差几个像素。我的标准写法是这样的:外层Stack设clipBehavior: Clip.none,角标用Positioned(top: -6, right: -6),角标内部用Container配padding和BoxDecoration(borderRadius),再套一层Transform.rotate做斜切效果。为什么是-6而不是0?因为角标自身有 padding,视觉上要压在卡片的圆角边界上,-6差不多是圆角半径的一半,具体数值要按你卡片的实际圆角调。这个数值没有公式,就是视觉微调,我一般会开着调试模式反复挪三次。
再说遮罩。做图片上的"已售罄"蒙层,正确姿势是Positioned.fill(child: Container(color: Colors.black54)),然后把文字放在同一个Stack里、层级更高的位置。这里要注意Stack的绘制顺序就是子组件的声明顺序,后声明的盖在上面。如果你把蒙层写在文字后面,文字就被盖住了,这个错误我见过不止一次。
最后是悬浮按钮。Scaffold自带floatingActionButton,但那个位置是固定的右下角。如果设计要求按钮悬浮在内容上方、距离底部有一段自定义距离,就得自己用Stack+Positioned(bottom: 24, right: 16)来做。这里有个细节:按钮下方往往还需要一层渐变色背景来保证在任何内容上都看得清,做法是Positioned.fill铺一层从透明到底部深色的LinearGradient,再放按钮。整个结构从上到下三层,顺序不能乱。
注意:
Positioned的子组件如果自身没有确定尺寸(比如一个没有宽高的Container),在只写了left+top的情况下会退回成零尺寸,你会看到"明明写了代码但什么都不显示"。加个width+height,或者让子组件内部有个撑得起来的东西。
3. 屏幕相关尺寸:MediaQuery 里那几个字段到底该用谁
3.1 size、padding、viewPadding、viewInsets 的区别
MediaQuery.of(context)返回的MediaQueryData里,跟尺寸相关的字段至少有这么几个:size、devicePixelRatio、padding、viewPadding、viewInsets、textScaler。刚接触的人最容易混的是中间三个,我用自己的话重新定义一遍。
size是逻辑像素下的整块屏幕尺寸,注意是整块,包含了状态栏、导航栏占用的区域。你拿size.height去除以二搞"半屏高度",得到的半屏是含系统栏的,实际可见区域会比这个窄。
padding是被系统 UI 遮住、且不建议你放内容的区域,四边各有一个值。刘海屏的顶部、底部的手势条、横屏时的左右两侧,都会体现在这里。有一个关键特性:当键盘弹出时,padding.bottom会被自动压成 0,因为它认为底部已经被键盘占了,不再是"系统栏遮挡"。这个行为坑过很多人——他们在键盘弹出时读padding.bottom,发现值突然变了,以为是 bug。
viewPadding是系统 UI 的原始占位,不随键盘变化。上面那个问题用viewPadding.bottom就能拿到稳定的值。如果你要做"键盘弹起时内容上移、键盘收起后内容回落到安全区"这种效果,padding和viewPadding得配合着用,不能只盯着一个。
viewInsets专门表示被键盘、输入法等临时遮挡的区域,键盘弹起时viewInsets.bottom就等于键盘高度。注意是逻辑像素,不是像素点。
至于devicePixelRatio,它只在你要做像素级绘制或者跟原生做尺寸对齐时才有意义。日常布局完全不用管它,Flutter 的布局单位本来就是逻辑像素。
3.2 键盘弹起时布局错乱的真实原因
这是我被问得最多的一个问题:"为什么我一弹键盘,整个页面就往上挤,背景图也跟着变形?"
根本原因通常是把根容器设成了Scaffold(resizeToAvoidBottomInset: true)(默认值)却不希望它自动压缩。默认行为是:键盘弹起时,Scaffold的body会缩小,为键盘腾位置。对于表单页这很合理,但对于那些"背景铺满、内容居中、输入框在底部"的设计,缩小的后果就是背景图被压扁。
处理方式有两条路。一条是设resizeToAvoidBottomInset: false,让body保持原尺寸,键盘浮在上面。然后你自己控制需要避让的元素——通常是用Padding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom))包住输入框所在区域。这样背景不动,输入框跟着键盘走,视觉上最舒服。另一条是干脆用Stack把背景层和内容层分开,背景层用Positioned.fill固定,内容层才参与尺寸变化。
我个人的偏好是第一条路,因为改动最小、后期维护清楚。但要注意viewInsets只在你确实需要手动避让时才读它,如果你已经关掉了resizeToAvoidBottomInset又同时读了viewInsets做padding,很容易重复避让,出现"键盘弹起后输入框飞到屏幕中间"的情况。
3.3 把尺寸计算封装起来,别在 build 里到处写 MediaQuery
MediaQuery.of(context)是InheritedWidget的查询,每次调用都会建立依赖。写在一两个地方无所谓,但如果一个页面里出现十几次,每次键盘弹起、旋转屏幕都会触发整棵树重建,性能账不划算。更麻烦的是可读性——你很难一眼看出某个0.8 * size.height到底是为了什么。
我的做法是抽一个小的尺寸工具类,把常用的比例换算集中起来。核心思想是:以设计稿宽度为基准,按比例缩放,但给上下限做钳制,避免在小屏上元素过小、在大屏上元素过大。
class ScreenMetrics { final Size size; final EdgeInsets safePadding; final double keyboardHeight; ScreenMetrics(BuildContext context) : size = MediaQuery.sizeOf(context), safePadding = MediaQuery.paddingOf(context), keyboardHeight = MediaQuery.viewInsetsOf(context).bottom; // 可用高度:扣掉上下安全区 double get usableHeight => size.height - safePadding.top - safePadding.bottom; // 按设计稿比例换宽度,基准 375 double w(double designWidth) => size.width / 375 * designWidth; // 按比例换高度,但限制在合理区间,防止极端屏拉爆 double h(double designHeight) { final raw = usableHeight / 812 * designHeight; return raw.clamp(designHeight * 0.7, designHeight * 1.3); } // 键盘是否弹起 bool get keyboardVisible => keyboardHeight > 0; }这里用MediaQuery.sizeOf(context)而不是MediaQuery.of(context).size,是因为前者只订阅size这一个字段的变化,屏幕尺寸不变时不会因为键盘弹起而重建,这是 Flutter 后来专门优化的按字段订阅 API,能用就用。
调用方式也很干净:在build里final m = ScreenMetrics(context);,然后m.w(24)、m.h(120)。这样所有魔法数字都有了语义,后面接手的人一眼能看懂。
提示:
clamp的上下限我一般设成 0.7 和 1.3。设得更宽,极端屏幕上的差异会很明显;设得更窄,适配就失去意义了。这个区间是我们几个项目反复调出来的经验值,不是标准答案,你可以按自己的设计规范调整。
4. Navigator 路由跳转:从 push 到 pop 的完整链路
4.1 push 与 pop 的返回值,才是页面通信的正解
Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage()))这行代码几乎人人会写,但很多人不知道它返回的是一个Future,而且这个Future会在目标页面pop的时候完成,携带pop时传回来的参数。
这意味着一个非常实用的模式:用push的返回值做页面间回传,完全不需要引入任何状态管理库。
Future<void> openDetail() async { final result = await Navigator.push<PurchaseResult>( context, MaterialPageRoute(builder: (_) => const DetailPage()), ); if (!mounted) return; // 关键一步 if (result == null) return; // 用户直接返回,没做选择 setState(() { status = result.status; }); }这里有两个必须注意的点。第一是if (!mounted) return;。await之后页面可能已经被销毁了(用户按了返回键、或者别的逻辑把页面弹掉了),此时再调setState会抛异常。这个异常在 debug 模式下只是红屏警告,在 release 模式下可能导致更隐蔽的问题,一定要养成加mounted判断的习惯。第二是null判断。用户直接点返回键或者滑动返回时,pop不携带参数,Future的结果就是null,不判空直接用就会空指针。
pop那一侧写法是Navigator.pop(context, PurchaseResult(status: 'paid')),第二个参数类型要跟push时的泛型对上。我建议专门为回传结果定义一个轻量的类,而不是直接传bool或String——因为需求会变,今天回传一个"是否购买",明天可能变成"购买状态 + 订单号",提前用类包一层,后面扩展时不用改调用处的所有代码。
4.2 命名路由与 onGenerateRoute:什么时候值得用
routes表加pushNamed是另一种常见写法。它的好处是路由集中在MaterialApp里,跳转时只传一个字符串,适合页面数量多、跳转关系复杂的项目。坏处是参数传递麻烦——pushNamed只能传arguments这一个动态类型的对象,类型安全全靠调用方自觉。
我的判断标准很朴素:如果参数是基本类型或者简单对象,用命名字符串没问题;如果参数结构复杂、或者需要编译器帮你检查,老老实实用MaterialPageRoute直接构造。因为pushNamed传参时如果类型写错,编译器完全不管,运行时才炸,排查成本高。
真要用命名路由又想要类型安全,可以上onGenerateRoute,在里面用settings.name做switch,把arguments强转成具体类型再构造页面。这样至少把类型转换集中在一个地方,出问题好定位。
onGenerateRoute: (settings) { switch (settings.name) { case '/detail': final args = settings.arguments as DetailArgs?; return MaterialPageRoute( builder: (_) => DetailPage(args: args ?? const DetailArgs.empty()), settings: settings, ); default: return null; } }注意最后返回null而不是抛异常,这样框架会走onUnknownRoute或者保持当前页面,不至于直接崩溃。
4.3 路由栈的四种操作,用错了就是白屏
除了push和pop,还有三个常用操作,用途差别很大:
| 方法 | 作用 | 典型场景 |
|---|---|---|
pushReplacement | 新页面替换当前页面,当前页出栈 | 登录页跳主页,不希望按返回回到登录 |
pushAndRemoveUntil | 新页面入栈并清空之前的栈 | 退出登录后回首页,清掉所有中间页 |
popUntil | 连续出栈直到满足条件 | 点击"返回首页"按钮,一次性弹掉多层 |
maybePop | 先询问是否允许出栈,再决定 | 页面有未保存内容时拦截返回 |
pushAndRemoveUntil的第二个参数是一个RoutePredicate,(route) => false表示清空所有,ModalRoute.withName('/')表示保留到首页为止。这两个写法的区别我头一次用的时候搞混过:前者把首页也弹掉了,结果新的页面成了栈底,返回键直接退出应用;后者才是"回首页、清中间"的正确姿势。
maybePop配合PopScope(旧版本叫WillPopScope)可以做"有未保存内容时二次确认"。这里有个坑:PopScope的onPopInvokedWithResult回调里如果决定不允许返回,必须确保canPop是false,否则系统手势已经把你的页面弹掉了,你再弹提示框就晚了。正确做法是把canPop绑到一个状态变量上,用户确认保存后再把它置为true,然后手动调一次pop。
5. 组合实战:一个全屏浮层页面的完整实现
5.1 需求拆解与结构设计
把前面几块拼起来,做一个小项目:点击卡片上的"查看大图",跳转到一个全屏浮层页面,背景是半透明黑,图片居中可缩放,右上角有关闭按钮,底部有安全区避让,关闭时回传"用户是否点了收藏"。
结构上我分成三层。最底层是Scaffold加backgroundColor: Colors.transparent,配合PageRouteBuilder的opaque: false让路由本身透明,这样能透出下层的页面。中间层是Positioned.fill的半透明遮罩,负责接收点击关闭。顶层是内容区,用SafeArea包住关闭按钮和底部操作栏。
为什么要用PageRouteBuilder而不是MaterialPageRoute?因为默认路由带一个从右往左的推入动画,全屏浮层用这个动画在视觉上是"页面切换"而不是"浮层弹出",用FadeTransition更符合预期。同时opaque: false是关键,不设的话下面那层页面会被完全遮住,透明背景就没意义了。
5.2 关键代码与尺寸计算过程
Route<void> buildPreviewRoute(BuildContext context, String imageUrl) { return PageRouteBuilder<void>( opaque: false, barrierColor: Colors.black54, transitionDuration: const Duration(milliseconds: 220), reverseTransitionDuration: const Duration(milliseconds: 180), pageBuilder: (ctx, animation, secondary) { return PreviewPage(imageUrl: imageUrl); }, transitionsBuilder: (ctx, animation, secondary, child) { return FadeTransition(opacity: animation, child: child); }, ); }尺寸上的计算点有这么几处。图片的最大高度不是屏幕高度,而是可用高度 - 顶部关闭按钮区域 - 底部操作栏区域。可用高度要扣掉MediaQuery.paddingOf(context).top和.bottom,否则在刘海屏上关闭按钮会被状态栏压住。我实测在某个 6.7 英寸的机型上,不扣安全区的话关闭按钮会有一半在状态栏下面,点击直接无效。
底部操作栏的高度我用固定值 64 加安全区,因为图标按钮的可点击区域至少要 48×48,加上下 padding 正好 64。这个数字不是拍脑袋来的,是 Material 的最小可点击尺寸规范加出来的。
关闭时的回传:
onPressed: () => Navigator.pop(context, _favorited),调用侧:
final favorited = await Navigator.push<bool>(context, buildPreviewRoute(context, url)); if (!mounted || favorited == null) return; if (favorited) { setState(() => _cardFavorited = true); }注意push的泛型这里写的是bool,跟PageRouteBuilder<void>的类型对不上。实际运行没问题,因为 Dart 的泛型在这里是擦除的,但严格来说应该把PageRouteBuilder的泛型改成bool保持一致。这种不一致在开启严格类型检查的 lint 规则下会报警告,我后来统一都改成了PageRouteBuilder<T>配合Navigator.push<T>。
5.3 手势返回与浮层的冲突处理
全屏浮层最容易出的问题是:Android 的手势返回、iOS 的边缘滑动返回,跟浮层内部的手势缩放打架。表现是用户想缩放图片,结果手指从屏幕左边缘滑进来,页面直接关了。
处理办法是给PageRouteBuilder设popGestureEnabled: false,把这个路由的侧滑返回关掉,只保留关闭按钮和点击遮罩关闭这两条路径。代价是用户少了一个习惯性的操作,所以我会在浮层上加一句轻提示,或者把关闭按钮做得足够显眼。
另一个冲突点是图片缩放组件自己会消费手势。如果遮罩层的点击关闭是GestureDetector包在最外层,图片缩放的InteractiveViewer在内层,内层会先拿到手势,不会误触发关闭。但如果你把点击关闭写在图片的父级、图片没有消费手势,那用户点图片也会关闭。我的做法是把"点击遮罩关闭"限定在遮罩那一层,图片区域自己用GestureDetector把点击事件吞掉。
注意:
barrierColor和opaque: false只用其中一个。两个都设的话,遮罩颜色会叠两层,比预期深很多。这个现象我第一次遇到时盯着代码看了半小时,最后发现是重复设了。
6. 常见问题与排查速查表
6.1 布局类问题:从现象反推原因
下面这张表是我这些年被问到、以及自己踩过的布局问题汇总,按"现象"排序,方便你直接对着查:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
Positioned写了但子组件不显示 | 子组件无确定尺寸,或超出边界被裁剪 | 补width/height,或设clipBehavior: Clip.none |
Stack背景图变形 | fit为默认loose,Stack尺寸被子组件决定 | 改fit: StackFit.expand或用Positioned.fill |
| 角标位置在真机上偏移 | 未考虑安全区,或圆角半径与偏移值不匹配 | 结合padding计算,微调负偏移量 |
| 键盘弹起后页面整体上移 | Scaffold默认resizeToAvoidBottomInset: true | 关掉该属性,手动用viewInsets.bottom避让 |
键盘弹起时读取padding.bottom得到 0 | padding会被键盘影响 | 改用viewPadding.bottom |
| 横屏后左侧内容被遮挡 | 未处理横屏时的padding.left | 用SafeArea或手动读四边 padding |
排查这类问题的通用思路是:打开调试绘制。在MaterialApp里设debugPaintSizeEnabled = true(写在main里),所有盒子会画出边框,一眼就能看出是约束给错了还是被裁了。这个功能我几乎每次遇到"位置不对"都会开,比盯着代码猜快十倍。
6.2 路由类问题:那些不像 bug 的 bug
路由相关的问题往往表现为"偶发"或者"特定机型",排查起来更费劲。说三个印象最深的。
第一个:setState报 "setState() called after dispose()"。原因是await push之后没有判断mounted。这个前面已经说了,但值得再强调——它不是偶发问题,只是很多时候你不看控制台就不知道它在报。养成习惯:任何await之后碰context或setState,先写mounted判断。
第二个:Navigator.of(context)找不到Navigator。这种情况通常发生在你把Navigator相关的调用写在了MaterialApp之上,或者用了嵌套的Navigator却没指定rootNavigator。如果项目里有多层Navigator(比如每个 tab 有独立路由栈),Navigator.of(context)会拿到最近的那个,想跳到全局页面就得写Navigator.of(context, rootNavigator: true)。
第三个:pushAndRemoveUntil之后按返回键直接退出应用。前面提过,(route) => false把栈底页也弹掉了。想保留首页就用ModalRoute.withName('/'),前提是首页注册时确实用了/这个名字。
6.3 我自己的三条硬规则
干了几年 Flutter,有三个习惯是强制自己遵守的,分享出来供参考。
第一条:路由跳转统一走封装方法,不直接写Navigator.push。封装一层AppNavigator.toDetail(context, args),好处是跳转参数集中管理、动画效果统一、后续想换成别的路由方案(比如某些场景用嵌套导航)只改一处。代码里到处散着MaterialPageRoute的项目,后期改动画风格会非常痛苦。
第二条:屏幕尺寸只在build开头读一次,往下传。不要在每个子组件的build里都MediaQuery.of,一是性能,二是当父组件和子组件对"可用高度"的理解不一致时,布局会错得莫名其妙。传下去的ScreenMetrics对象是同一个,大家用的是同一套坐标系。
第三条:Positioned的负偏移一律加注释说明为什么。top: -6这种数字,三个月后你自己都忘了它是干什么的。写一句// 压住卡片圆角,视觉上贴边只要三秒,省下的排查时间可能是半小时。
7. 一点个人体会
Stack和Positioned这套东西,看起来简单,但它把"约束"这个概念暴露得最彻底。你写Container的时候可以糊里糊涂地过,写Positioned的时候糊弄不过去——要么子组件有尺寸,要么被裁剪,要么约束冲突,框架会用最直白的方式告诉你哪里想错了。所以我一直觉得,把这几个组件吃透,比多看十个复杂组件的文档更有价值,因为它训练的是对布局约束的直觉。
Navigator那边则相反,API 就那几个,难点全在时序和生命周期上。await之后的世界是另一个世界,页面可能没了、context可能失效了、状态可能已经被别人改了。我现在的写法是:凡是有await的地方,下一行先写if (!mounted) return;,写完这一行再想业务逻辑。这个顺序不能反,反了就容易漏。
至于屏幕尺寸,我的建议是别太早抽象。项目初期就老老实实写MediaQuery,等你在三四个页面里重复了同样的换算逻辑,再抽工具类。提前抽象出来的东西,往往跟你真正需要的不一样——我第一版封装的尺寸工具,用了一周就发现高度换算的基准选错了,返工改了一堆调用点。让重复先发生,再消除它,这个顺序比反过来舒服得多。