
Flutter 可复用公共组件库设计与落地AppDialog/BottomSheet 等实战作者FungLeo 适用Flutter 3.x / Material 3目标把每个页面都长差不多的那部分 UI 抽成一层薄封装统一风格、统一质量。前言各位看官这是主题系列的最后一篇了。前面 Flutter Material 3 从 0 搭品牌主题系统四件套实战全记录 讲主题四件套、Flutter 把第三方 UI 库渐进迁回 Material 3组件映射总表 四批次替换实战 讲 UI 库迁移、BrandColors 那篇讲颜色收口这篇聊聊公共组件库。先说说我为什么会去抽这个东西。有一次做需求我要在一个列表页上加个确认删除的弹窗。很简单对吧我顺手就写了showDialog(context:context,builder:(ctx)AlertDialog(title:constText(确认),content:constText(确定删除),actions:[TextButton(onPressed:()Navigator.pop(ctx,false),child:constText(取消)),FilledButton(onPressed:()Navigator.pop(ctx,true),child:constText(确定)),],),);写完提交测试同学过来说这个弹窗跟隔壁那个页面的不一样啊。我去看了一眼隔壁好家伙——按钮顺序是反的确定在左文案是是/否而且它用的是Navigator.of(context).pop()不带返回值外面靠一个变量接。再翻第三个页面那个的取消按钮居然是OutlinedButton。三个页面三种弹窗。全都是我们自己人写的而且每一个单独看都没毛病。这其实就是前面 BrandColors 那篇里说的风格漂移只不过那篇漂的是颜色这里漂的是交互。说实话这事儿怪不着谁。写业务的时候谁会专门去翻别的页面看弹窗长什么样呢大家都是凭手感写的手感自然各不相同。所以解法还是那个别靠自觉靠机制。我们抽了一个lib/widgets/公共组件库目前大概 20 个组件。这篇就讲讲怎么设计、怎么落地。本文要点抽组件前先做分析同一个 UI 出现第三次才抽别提前造一堆用不上的弹窗 / Toast 这类调一下就完事的东西用abstract final class 静态方法消灭 builder 样板多种形态的组件用命名构造器把形态编进类型非法状态编译期就不存在公共组件内部只用BrandColors对加个自定义颜色的需求要克制开了口子就守不住组件只管展示不碰数据否则复用性归零光有组件不够配套规范文档 code review 才有据可依一、先别急着抽先做分析我想先泼一盆冷水抽组件之前先搞清楚哪些东西真的值得抽。我见过一种很典型的过度设计项目刚起步先花两周搭一个公共组件库把想象中会用到的组件都写一遍。结果做到后面发现一半组件从来没被第二个页面用过另一半因为当初没预料到实际需求参数设计得完全不合适用的时候还得改。这就是典型的脱裤子放屁。我的判断标准很土同样的 UI 出现第三次的时候才考虑抽。出现一次正常写。出现两次留意一下可能是巧合。出现三次这就是模式了抽。按这个标准我们动手前先跑了一轮扫描把项目里重复的 UI 模式列出来# 看看有多少页面在自己拼搜索框➜grep-rlnTextFieldlib/pages/|wc-l# 看看 showDialog 散落在多少处➜grep-rnshowDialog\|showModalBottomSheetlib/|wc-l# 看看有多少处在手写空态➜grep-rn暂无数据\|没有更多lib/跑完就有数了搜索框 12 个页面各写各的、弹窗 20 多处、空态提示 15 处文案还不统一。这些就是该抽的。而某些我原以为肯定要抽的东西实际只出现过一次那就先放着。别盲目抽先有分析报告。这是我最想说的第一条。二、组件清单节选分析完我们最终沉淀下来的组件大概是这些组件用途AppSearchBar统一搜索栏聚焦边框 透明底AppFormSection表单区块标签 必填 * 间距AppActionBar底部操作栏单提交 / 多按钮AppDialog统一弹窗confirm/alert静态方法AppBottomSheet底部抽屉拖拽手柄 居中标题 关闭AppToast全局轻提示AppTextarea多行输入带字数计数浮层AppErrorBody/AppEmptyBody错误态 / 空态AppFilterChips横滚药丸筛选条AppTag/AppInfoRow状态标签 / 图标信息行AppSegmentedTab/AppScopeToggle分段 Tab / 二选一范围切换AppListFooter/AppStickyHeader加载更多 footer / 吸顶头统一用App前缀好处是打字的时候输入App就能唤起补全一眼看到全部可用组件。这个小细节对新同学特别友好——他不需要读文档IDE 会告诉他有什么。三、设计原则1. 用abstract final class 静态方法消灭样板对于弹窗、Toast 这种调一下就完事的东西我不建议做成 Widget做成静态方法更顺手/// 统一弹窗入口。/// abstract final 表示既不能被继承也不能被实例化纯粹当命名空间用。abstractfinalclassAppDialog{/// 确认弹窗一行调用拿到 bool 结果没有 builder 样板staticFutureboolconfirm({requiredBuildContextcontext,requiredStringtitle,requiredStringcontent,StringcancelText取消,StringconfirmText确定,})async{finalresultawaitshowDialogbool(context:context,builder:(ctx)AlertDialog(title:Text(title),content:Text(content),actions:[TextButton(onPressed:()Navigator.pop(ctx,false),child:Text(cancelText),),FilledButton(onPressed:()Navigator.pop(ctx,true),child:Text(confirmText),),],),);// 用户点遮罩关闭时 result 是 null统一收敛成 falsereturnresult??false;}}业务页里就一行finalokawaitAppDialog.confirm(context:context,title:确认,content:确定删除,);if(ok){// 执行删除}对比一下开头那十几行清爽多了对吧。这里有两个细节值得单独说说一是return result ?? false这个兜底。showDialog返回的是可空类型因为用户可能点遮罩或者按返回键关掉弹窗这时候你的Navigator.pop(ctx, true/false)根本没执行拿到的就是null。如果不收敛调用方就得每次写if (ok true)。而人是会偷懒的总有人写成if (ok)——然后编译报错再改成if (ok!)好了用户点个遮罩就崩溃。把它收敛在组件内部这类问题从源头上就不存在了。二是abstract final class这个写法。这是 Dart 3 的类修饰符含义是这个类既不能被继承也不能被实例化。为什么要这么严因为AppDialog本质上只是个命名空间我们要的就是AppDialog.confirm(...)这个调用形式没人应该去new AppDialog()。用abstract final声明出来编译器直接帮你把错误用法堵死了比写注释请勿实例化靠谱一万倍。如果你的项目还在 Dart 2退而求其次可以用私有构造器classAppDialog{AppDialog._();// Dart 2 时代的老办法防止被实例化staticFutureboolconfirm({...})async{/* ... */}}2. 命名构造器 私有字段把形态编进类型里有些组件会有几种形态。比如底部操作栏有时候是一个大提交按钮有时候是并排几个按钮。最容易想到的写法是加参数AppActionBar(submitText: ..., items: ...)然后内部判断哪个不为 null。但这样有个问题——调用方可以两个都传也可以两个都不传这两种情况你都得处理还得想好优先级。更好的做法是用命名构造器把形态编进类型里classAppActionBarextendsStatelessWidget{/// 形态一单个提交按钮constAppActionBar.submit({super.key,requiredStringtext,requiredVoidCallbackonPressed,}):_itemsconst[],_submitTexttext,_onSubmitonPressed;/// 形态二并排多个操作按钮constAppActionBar.actions({super.key,requiredListActionItemitems,}):_itemsitems,_submitTextnull,_onSubmitnull;finalListActionItem_items;finalString?_submitText;// 非 null 即代表 submit 形态finalVoidCallback?_onSubmit;overrideWidgetbuild(BuildContextcontext){// 靠 _submitText 是否为空来分流两种形态互斥且必有其一return_submitText!null?_buildSubmit():_buildActions(_items);}// ...}这么写之后调用方只有两条路可走// 提交形态AppActionBar.submit(text:保存,onPressed:_onSave);// 多按钮形态AppActionBar.actions(items:[/* ... */]);非法状态在编译期就不存在了你不可能构造出一个既有提交按钮又有多按钮的怪东西。这个思路在 Flutter 里挺常用的官方的ListView/ListView.builder/ListView.separated就是这个路子。字段用下划线开头设为私有是因为它们只是内部实现细节不希望外部读取。3. 所有组件只用BrandColors不接外部色值公共组件是设计系统的门面所以内部颜色一律走 BrandColors 那篇讲的语义色。这条要主动做减法即使有同学来提需求说能不能给AppTag加个color参数我这儿要个紫色的也建议先问一句为什么需要紫色。因为一旦开了这个口子组件就守不住了。今天加个color明天加个borderRadius后天加个padding——加到最后这个组件就退化成了一个参数特别多的 Container它统一风格的价值荡然无存。我的处理方式一般是如果确实是一个新的语义比如待处理状态需要一个新颜色那就在BrandColors里加一个语义常量然后在组件内部支持这个语义枚举如果只是某个页面想搞点特殊那就……委婉拒绝哈。真的特殊到这个地步那就说明它不该用公共组件。4. 组件自己不管数据只管展示这条是红线我单独拎出来说。公共组件里不要出现任何请求、任何全局状态读取。它应该是纯粹的输入参数 → 渲染 UI 抛出回调。// ✅ 好数据从外面来事件往外面抛AppFilterChips(items:options,selected:currentIndex,onChanged:(i)setState(()currentIndexi),);一旦组件内部自己去取数据它就跟具体场景绑死了第二个页面想复用的时候一定会发现它取的不是我要的东西。那就白抽了。四、落地过程中踩的坑光讲原则有点虚说两个我实际踩到的。坑一BuildContext 用错弹窗关不掉写AppDialog的时候我一开始是这么写的// ❌ 错误示范builder 里用了外层的 contextfinalresultawaitshowDialogbool(context:context,builder:(_)AlertDialog(actions:[TextButton(onPressed:()Navigator.pop(context,false),// 注意这里用的是外层 contextchild:constText(取消),),],),);看着好像没问题很多时候也确实能跑。但在嵌套 Navigator 的场景下比如底部 Tab 每个都有自己的 Navigator这个外层context找到的可能是错误的那个 Navigator结果就是点取消把整个页面 pop 掉了弹窗还在那儿杵着。正确做法就是前面代码里那样用 builder 给你的那个ctx。这个 context 是弹窗自己那一层的pop 的一定是弹窗本身。排查这个问题的时候我绕了不少弯路一度以为是路由配置的问题。后来是靠打印 context 的层级才定位到// 临时加一行看看当前 context 找到的是哪个 NavigatordebugPrint(navigator ${Navigator.of(ctx)});坑二异步之后用 contextlint 会警告写异步弹窗的时候你大概率会撞上这个警告info • Dont use BuildContexts across async gaps • use_build_context_synchronously意思是await之后这个 widget 可能已经被销毁了你再用它的context就是访问一个失效的对象运行时可能崩。正确的处理是加mounted检查finalokawaitAppDialog.confirm(context:context,title:确认,content:确定删除);// await 之后先确认组件还活着再用 contextif(!mounted)return;if(ok){AppToast.show(context,已删除);}我知道有些同学嫌烦会用// ignore: use_build_context_synchronously一把把它按掉。我劝各位看官别这么干哈——这个警告是真能救命的用户在弹窗还开着的时候点了返回键就是妥妥的崩溃现场。五、光有组件不够还得有规则最后说个非技术但很关键的点。组件抽出来了不代表大家就会用。总有人因为不知道、或者觉得我这个场景有点特殊绕过去自己写一个。所以组件库一定要配套规则写进团队的 UI 规范文档关于硬性规则的章节比如列表页筛选条一律使用AppFilterChips禁止自绘确认类弹窗一律使用AppDialog.confirm禁止直接调showDialog空态 / 错误态一律使用AppEmptyBody/AppErrorBody。有了这几条code review 的时候就有据可依了。看到 diff 里出现裸的showDialog一句用AppDialog哈就能打回去不用每次都重新论证一遍为什么。小结好啦公共组件库这块就聊完了顺带整个主题系列也告一段落。捋一下核心的几条公共组件的价值不在少写几行而在风格统一 质量门禁。一个组件被全站复用的前提是它自己的质量过得去。抽组件前先做分析同一个 UI 出现第三次再抽。别盲目抽更别提前造一堆用不上的。abstract final class 静态方法 / 命名构造器是 Flutter 组件 API 的优雅范式。把非法状态在编译期干掉比运行时判断强得多。组件内部只用BrandColors对加个自定义颜色参数的需求要克制开了口子就守不住。组件只管展示不碰数据否则复用性归零。配套规范文档否则总会有人绕过去。说实话这一层封装的代码量真不大二十个组件加起来也就一两千行。但它带来的变化挺明显的新同学接手页面不用再纠结弹窗该长什么样AppDialog.confirm一行搞定样式自动就对了。这种想写错都难的状态才是组件库真正的价值。那么各位看官您的项目里公共组件是怎么划边界的有没有遇到过抽早了或者抽过头了的情况欢迎在评论区聊聊哈。如果这篇对您有点帮助也希望看官您用发财的小手点个小赞哈本文由 FungLeo 主导Deepseek 优化校阅转发请注明首发地址谢谢大家相关阅读Flutter/Android Release 包连不上网AndroidManifest INTERNET 权限排查实录Flutter dart-define 实现 dev/正式双构建调试代码正式包零残留Flutter 接入 Alice 调试浮窗一个顶层 final 抢跑把 release 网络整没了Flutter Android 构建突发红字一个跟通知无关的库逼你开 core library desugaringFlutter Debug 红屏、Release 灰屏你的 release-only bug只是异常被藏起来了Flutter Material 3 从 0 搭品牌主题系统四件套实战全记录Flutter 把第三方 UI 库渐进迁回 Material 3组件映射总表 四批次替换实战Flutter BrandColors 设计 Token 集中管理消灭硬编码色值实战