XB Scaffold
基于 Provider 封装的 Flutter 脚手架,集成主题、dialog、toast、actionSheet 等常用控件,提供完整的 MVVM 架构解决方案(路由能力由配套包 xb_simple_router 提供)。
特性
- 🏗️ 完整的 MVVM 架构:基于 Provider 的状态管理
- 🎨 主题系统:支持多主题切换和自定义主题
- 📱 丰富的 UI 组件:内置常用组件和工具类
- 🔄 生命周期管理:完整的页面和组件生命周期
- 🌐 跨平台支持:支持 iOS、Android、Web、Desktop
- 📋 悬浮列表:支持分组头部悬浮的 ListView
- 🛠️ 工具集合:事件总线、定时器等实用工具
安装
在 pubspec.yaml 中添加依赖:
dependencies:
xb_scaffold: ^1.0.2
然后运行:
flutter pub get
在其他工程使用 CLI
如果你希望在业务工程里直接输入 xb.page xb_test_generate_widget lib/src/、xb.parsemodel lib/model/user_model.dart(而不是每次输入 dart run xb_scaffold:xb ...),推荐直接使用 xb.setup 一键安装命令工具。
1. 在业务工程添加依赖
在业务工程的 pubspec.yaml 中添加:
dependencies:
xb_scaffold: ^1.0.2
然后执行:
flutter pub get
2. 执行 xb.setup(推荐)
xb.setup 用于一键初始化 CLI 短命令环境,适合首次接入时执行一次。
执行命令:
dart run xb_scaffold:xb xb.setup
该命令会自动执行以下操作:
- 在当前项目根目录生成
Makefile - 自动执行
make install-cli - 处理 PATH:
- macOS:向
~/.zshrc追加export PATH="$HOME/.local/bin:$PATH"(如不存在) - Windows:提示你手动把
%USERPROFILE%\.local\bin加入环境变量
- macOS:向
- 自动执行
xb.extension(默认目录lib/) - 自动执行
xb.updateimg(默认图片目录./assets/images) - 在项目根目录生成
README_XBScaffold.md(xb_scaffold 框架完整文档,每次执行静默覆盖为当前包版本) - 安装
xb_scaffold_code_snippets代码片段(每次执行静默覆盖,保持与包版本同步):- VS Code / Cursor / Qoder:写入项目级
.vscode/xb_scaffold_code_snippets.code-snippets,随项目走、可进 Git,所有 VS Code 系 IDE 共用同一份,团队无需各自执行 setup - Android Studio / IntelliJ:转换为 Live Templates 组写入用户级 templates 目录(JetBrains 无项目级 Live Templates,需重启 IDE 生效)
- VS Code / Cursor / Qoder:写入项目级
片段收录 xb_page、xb_widget、xb_vmless_widget、xb_vm、xb_int_parse、xb_double_parse、xb_string_parse、xb_bool_parse、xb_list_parse、xb_padding、xb_dialog、xb_select、xb_select_multi、xb_notifier 共 14 个,仅包含 xb_scaffold 真实存在的 API,统一 xb_ 前缀,不会污染其他项目的补全。
注意:xb.setup 无法直接刷新你“当前已打开”的终端会话。
执行完成后,请在当前终端手动运行:
source ~/.zshrc
hash -r
3. 使用示例
# 打印模板到终端(方便复制)
xb.page xb_test_generate_widget
# 推荐:传「文件名 + 目录」直接生成两个文件
xb.page xb_test_generate_widget lib/src/
# => lib/src/xb_test_generate_widget.dart
# => lib/src/xb_test_generate_widget_vm.dart
# 也支持传完整文件路径(同样会额外生成 *_vm.dart)
xb.page xb_test_generate_widget lib/src/xb_test_generate_widget.dart
# 其他命令
xb.widget UserCard lib/widgets/user_card.dart
# 生成主题扩展目录和模板文件
xb.extension
xb.extension lib/public/
# 根据图片目录更新 app_theme_image.dart(默认 ./assets/images)
xb.updateimg
xb.updateimg ./assets/images
# 解析模型文件,生成 fromJson / toJson 代码片段(输出到终端)
xb.parsemodel lib/model/user_model.dart
# 根据 JSON 字符串生成模型代码(输出到终端或文件)
xb.newmodel add_device_video_manage_unnormal_model '{"deviceId":"1","deviceName":"A","accessStoreName":"S","code":"0","msg":"ok"}'
xb.newmodel add_device_video_manage_unnormal_model lib/model/
xb.newmodel add_device_video_manage_unnormal_model lib/model/ '{"deviceId":"1","deviceName":"A"}'
xb.newmodel add_device_video_manage_unnormal_model lib/model/add_device_video_manage_unnormal_model.dart '{"deviceId":"1"}'
xb.page 的生成规则:
- 第一个参数是文件名(推荐 snake_case)
- 自动将文件名转为类名(PascalCase),例如
xb_test_generate_widget->XbTestGenerateWidget - 生成页面文件和 VM 文件两个文件
- 两个文件顶部会互相
import
xb.extension 的生成规则:
- 不传路径时默认使用命令运行目录下的
lib/ - 输入目录路径(例如
lib/public/) - 自动创建目录
xb_scaffold_extension - 在该目录生成以下文件:
app_theme_color.dartapp_theme_font_size.dartapp_theme_font_family.dartapp_theme_font_weights.dartapp_theme_image.dartapp_theme_space.dart- 文件级跳过:已存在的文件保留不动(不会覆盖已有内容),仅补齐缺失的文件
xb.updateimg 的更新规则:
- 自动查找
app_theme_image.dart(优先lib/public/xb_scaffold_extension/app_theme_image.dart) - 默认图片目录:
./assets/images - 支持自定义图片目录:
xb.updateimg <image_dir> - 递归扫描图片目录(忽略
.DS_Store) - 对
app_theme_image.dart增量追加: String get xxx => imgPath('relative/path');- 若出现同名文件(仅扩展名不同),会自动加后缀区分:
- 例如
ic_add_device_hint.png/ic_add_device_hint.svg - 会生成
ic_add_device_hint_png/ic_add_device_hint_svg
xb.parsemodel 的规则:
- 用法:
xb.parsemodel <dart_model_file_path> - 读取指定 Dart 模型文件,按类定义解析字段
- 在终端输出每个类对应的
fromJson/toJson代码片段 - 非空字段会自动追加默认值:
String -> ""int -> 0double -> 0.0bool -> falseList -> []List<基础类型>生成xbParseList<T>(...)List<对象类型>生成xbParseList(..., factory: Type.fromJson)- 对象类型字段生成
xbParse(..., factory: Type.fromJson)
xb.parsemodel 示例:
输入文件(lib/model/user_model.dart):
class UserModel {
String name;
int age;
List<Tag>? tags;
}
class Tag {
String? id;
}
xb.newmodel 的规则:
- 用法:
xb.newmodel <file_name> [out_path] <json_string> file_name用于生成类名(自动转换为首字母大写驼峰)json_string必须是 JSON 对象(顶层必须是{})- 不传
out_path时,模型代码输出到终端 - 传
out_path时: - 若是目录路径:输出到
<out_path>/<file_name>.dart - 若是文件路径:直接输出到该文件(自动补
.dart) - 生成内容包括:
import 'package:xb_scaffold/xb_scaffold.dart';- 可空字段声明
- 构造函数
fromJson(使用xbParse/xbParseList)toJson
xb.newmodel 示例:
执行命令:
xb.newmodel add_device_video_manage_unnormal_model '{"deviceId":"1","deviceName":"A","accessStoreName":"S","code":"0","msg":"ok"}'
输出示例(节选):
import 'package:xb_scaffold/xb_scaffold.dart';
class AddDeviceVideoManageUnnormalModel {
String? deviceId;
String? deviceName;
String? accessStoreName;
String? code;
String? msg;
AddDeviceVideoManageUnnormalModel({
this.deviceId,
this.deviceName,
this.accessStoreName,
this.code,
this.msg,
});
AddDeviceVideoManageUnnormalModel.fromJson(Map<String, dynamic> json) {
deviceId = xbParse<String>(json['deviceId']);
deviceName = xbParse<String>(json['deviceName']);
accessStoreName = xbParse<String>(json['accessStoreName']);
code = xbParse<String>(json['code']);
msg = xbParse<String>(json['msg']);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> retMap = {};
retMap['deviceId'] = deviceId;
retMap['deviceName'] = deviceName;
retMap['accessStoreName'] = accessStoreName;
retMap['code'] = code;
retMap['msg'] = msg;
return retMap;
}
}
执行命令:
xb.parsemodel lib/model/user_model.dart
输出示例(节选):
----------------------------UserModel----------------------------
UserModel.fromJson(Map<String, dynamic> json) {
name = xbParse<String>(json['name']) ?? "";
age = xbParse<int>(json['age']) ?? 0;
tags = xbParseList(json['tags'], factory: Tag.fromJson);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> retMap = {};
retMap['name'] = name;
retMap['age'] = age;
if (tags != null) {
retMap['tags'] = tags!.map((v) => v.toJson()).toList();
}
return retMap;
}
4. 不安装也能用(备用方式)
如果你不想安装本地命令,仍可直接执行:
dart run xb_scaffold:xb xb.setup
dart run xb_scaffold:xb xb.page xb_test_generate_widget
dart run xb_scaffold:xb xb.page xb_test_generate_widget lib/src/
dart run xb_scaffold:xb xb.extension
dart run xb_scaffold:xb xb.extension lib/public/
dart run xb_scaffold:xb xb.updateimg
dart run xb_scaffold:xb xb.updateimg ./assets/images
dart run xb_scaffold:xb xb.parsemodel lib/model/user_model.dart
dart run xb_scaffold:xb xb.newmodel add_device_video_manage_unnormal_model '{"deviceId":"1"}'
dart run xb_scaffold:xb xb.newmodel add_device_video_manage_unnormal_model lib/model/ '{"deviceId":"1"}'
5. 生成 AI 自装指南(可选,推荐)
xb_scaffold 并非主流框架,AI 编程工具默认不了解它的 API。包内内置了一份全英文的 AI Skill(skill/xb-scaffold/,含 SKILL.md 和详细到组件参数/样式的 reference 文档)。
由于各家 AI IDE(Qoder、Claude Code、Cursor 等)的 skill/规则目录与格式互不兼容,xb.skill 不直接安装到任何特定工具,而是在项目根生成一份厂商中立的自装指南:
dart run xb_scaffold:xb xb.skill
# 已安装 xb 短命令的项目:
xb.skill
| 参数 | 说明 |
|---|---|
| (默认) | 在当前目录生成 xb-scaffold-ai-guide.md |
--out <path> |
自定义生成位置(如 docs/xb-scaffold.md) |
--force |
覆盖已存在的文件,不再询问 |
指南文档包含两部分:
- 给 AI 的指令:如何把下方的知识库转换成它当前工具的原生 skill/规则(含常见工具映射表:
.qoder/skills/、.claude/skills/、.cursor/rules/等); - 完整知识库:用
<!-- section: ... -->标记切分的 SKILL.md 与 4 份 reference 文档全文。
之后告诉你的 AI 一句话即可:
读取并执行项目根目录下
xb-scaffold-ai-guide.md中的安装指令。
无论你(或团队成员)使用哪种 AI IDE,它都会自己把知识装成自己认识的样子。
注意:xb.setup 会在末尾自动生成该指南,接入即生成,一般无需单独执行。
使用 xb.build 打包(新手教程)
xb.build 是 XB Scaffold 自带的打包命令。照着本文一步步做,不需要理解原理也能把项目打包成安装包。本文假设你已经会用 Flutter 写代码,只是还没打过包。
1. 准备工作(每台电脑只需做一次)
① 检查环境
- 打 iOS 包:必须用 Mac,并装好 Xcode(App Store 搜 "Xcode" 安装)
- 打 Android / 鸿蒙包:Mac 或 Windows 都可以
- Flutter 已安装:打开终端输入
flutter --version,能显示版本号即可 - 项目依赖了 XB Scaffold:打开项目
pubspec.yaml,dependencies里有xb_scaffold: ^版本号(版本太老会没有 xb.build,见第 5 节排错)
② 打开终端,进入项目目录
- Mac 打开终端:按
⌘ + 空格,输入"终端"回车 - 进入项目:输入
cd(cd 后有一个空格),把项目文件夹拖进终端窗口,回车。例如显示成:cd /Users/你的用户名/你的项目
③ 下载项目依赖
flutter pub get
看到 Got dependencies! 即成功。
④ 配置 iOS 签名(只打 Android / 鸿蒙的人跳过这步)
苹果要求每个 App 都必须有"签名",签名绑定一个 团队编号(Team ID)。xb.build 需要你把编号填进配置文件,只需做一次。
找到你的团队编号,三选一:
- 方式 A(最省事):直接问负责打包/上架的同事要。公司项目一般只有一个固定编号,例如
273JDFD2Q8 - 方式 B(从证书看):打开 Mac 的"钥匙串访问"应用 → 搜索
Apple Development或iPhone Distribution→ 双击证书 → 名称中括号里的字母数字(例如(273JDFD2Q8))就是 Team ID - 方式 C(从老工程抄):找一个"能在 Xcode 里正常打包"的 iOS 工程,用文本编辑器打开
ios/Runner.xcodeproj/project.pbxproj,搜索DEVELOPMENT_TEAM,等号后的内容就是
拿到编号后,在终端执行下面命令(把 XXXXXXXXXX 换成你的编号,整体复制执行即可,没有报错就是成功):
cat > ~/.xb_build_config.json << 'EOF'
{
"ios": {
"signing": {
"style": "automatic",
"team": "XXXXXXXXXX"
}
}
}
EOF
说明:style: automatic 表示让 Xcode 自动管理证书和描述文件,绝大多数人用它就够了,不需要手动碰证书。
⑤ 确认 Xcode 已登录 Apple 账号(打 iOS 需要)
打开 Xcode → 菜单 Settings…(旧版叫 Preferences…)→ Accounts,能看到 Apple ID 即可。公司电脑一般已配好,不确定就打开看一眼。
2. 开始打包
① 先提交代码(⚠️ 很重要,否则会打旧包)
xb.build 是在代码仓库"最近一次提交"的基础上打包的(保证打出来的包和代码一致)。如果你刚改了代码但还没 git commit,打出来的包不含你的新改动。两种选择:
- 正式打包:先提交,再打包
git add . git commit -m "准备打包" - 快速自测:不想提交,就想用当前代码打一个试试 → 在打包命令末尾加
--no-worktree(见下)
② 执行打包
打 iPhone 包:
dart run xb_scaffold:xb xb.build --platform ios --build-name 1.0.0 --build-number 1
只有两个参数需要理解:
--build-name 1.0.0:版本号,给用户看的,通常数字.数字.数字,按你们团队的版本计划填--build-number 1:打包序号,每次打包都要比上一次大(苹果硬性要求),不知道当前是多少就先填1,之后每次 +1
打安卓包(--build-name / --build-number 与 iOS 通用,可带可不带;不传则用工程配置的版本):
dart run xb_scaffold:xb xb.build --platform android
打鸿蒙包(同样支持版本参数;默认生成 .hap,想要 .app 加 --ohos app):
dart run xb_scaffold:xb xb.build --platform ohos
懒人选项:什么都不带,自动检测项目能打哪些平台,全部打一遍:
dart run xb_scaffold:xb xb.build
(如果装过 xb.setup 短命令,把上面的 dart run xb_scaffold:xb xb.build 换成 xb.build 即可)
③ 等待完成
- 首次打包比较慢(iOS 可能 20~40 分钟:要下载依赖 + 全量编译),之后会快很多
- 中途不要关终端
- 看到这行字就是成功:
全部平台构建完成,产物目录: /Users/你的用户名/Desktop/xb_build
3. 打包完成,去哪里拿安装包?
所有产物都在 桌面上的 xb_build 文件夹(即 ~/Desktop/xb_build):
| 平台 | 产物文件 | 说明 |
|---|---|---|
| iOS | 项目名.xcarchive |
Xcode 归档包。装真机 / TestFlight / 上架还需用 Xcode 再导出一次(可让负责上架的同事操作) |
| Android | 项目名.apk |
可直接发给别人安装,或上传各应用市场 |
| 鸿蒙 | 项目名.hap / 项目名.app |
安装到鸿蒙设备,或上传华为应用市场 |
4. 打包前建议先试跑(可选但推荐)
第一次打包前,先做一次"只检查不动手":
dart run xb_scaffold:xb xb.build --dry-run
它会检查电脑环境、git 状态、签名配置是否齐全并打印出来,有问题可以提前发现,不用等 40 分钟。
5. 常见问题
| 现象 | 原因 | 解决办法 |
|---|---|---|
提示 xb.build 不是有效命令 |
当前依赖的 xb_scaffold 版本还没有 xb.build | 升级 pubspec 里的 xb_scaffold 到含 xb.build 的版本,重新 flutter pub get |
签名时报 No profiles ... were found / 找不到 team |
Team ID 填错,或这台 Mac 的 Xcode 没登录对应 Apple 账号 | 重新核对第 1 步 ④ 的编号;确认 Xcode 已登录(第 1 步 ⑤) |
签名时报 An App ID with identifier 'com.xxx' is not available |
bundle id(com.xxx.xxx)被别人注册过了 |
用 Xcode 打开 iOS 工程 → Signing & Capabilities → 把 Bundle Identifier 改成没被占用的(如 com.你的公司.你的项目),提交后重新打包 |
| 打出来的包没有我最新改的代码 | 未提交的改动不会进包 | 先 git commit 再打包,或加 --no-worktree 快速自测 |
| 下载依赖时网络报错 | 网络波动 | 直接重跑打包命令(有缓存,重试通常能过) |
安卓包解析依赖时报 Read timed out |
部分依赖只在公司内网 maven 仓库有,且首次必须联网下载 | 连上公司网络/VPN 后重跑一次,成功后依赖永久缓存,之后断网也能打包 |
| 看到看不懂的报错 | — | 把终端完整输出复制给同事 / 在 XB Scaffold 仓库提 issue |
6. 进阶命令速查
| 命令 | 作用 |
|---|---|
... xb.build --dry-run |
只检查环境与配置,不打包 |
... xb.build --no-worktree |
不用 git 已提交代码,直接用当前代码打包(快速自测) |
... xb.build --open |
打包完成后自动打开产物文件夹 |
... xb.build --project-dir /别的/项目路径 |
在任意目录给其他项目打包 |
... xb.build --platform ios android |
同时打多个平台 |
快速开始
1. 初始化应用
import 'package:flutter/material.dart';
import 'package:xb_scaffold/xb_scaffold.dart';
void main() {
initXBErrorHandler(
// 可选:上报到你的监控系统
reporter: (error, stack) {
debugPrint('Captured error: $error');
},
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return XBMaterialApp(
title: 'XB Scaffold Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: XBScaffold(
// 配置主题
themeConfigs: [
XBThemeConfig(
primaryColor: Colors.blue,
imgPrefix: "assets/images/theme1/",
),
XBThemeConfig(
primaryColor: Colors.red,
imgPrefix: "assets/images/theme2/",
),
],
// 自定义 Loading 样式(可选)
loadingBuilder: (context, msg) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
if (msg != null) ...[
SizedBox(height: 16),
Text(msg),
],
],
),
);
},
// Toast 背景颜色(可选)
toastBackgroundColor: Colors.black87,
child: const HomePage(),
),
);
}
}
initXBErrorHandler 建议在 runApp 前调用一次,用于统一接管异常捕获和上报。
参数说明(与源码一致):
reporter:自定义异常上报回调,签名是FutureOr<void> Function(Object error, StackTrace? stackTrace)errorWidgetBuilder:自定义页面构建异常时的兜底 WidgetdumpFlutterErrorToConsole:是否打印 Flutter 框架异常到控制台,默认trueenableErrorWidget:是否启用页面构建异常兜底 UI,默认trueenablePlatformDispatcherError:是否启用 root isolate 未捕获异常兜底,默认trueenableIsolateError:是否监听 isolate 异常,默认false
更完整的初始化示例:
void main() {
initXBErrorHandler(
reporter: (error, stack) async {
// 例如:上报到 Sentry / Firebase Crashlytics / 自建日志平台
debugPrint('report error => $error');
if (stack != null) {
debugPrint('stack => $stack');
}
},
errorWidgetBuilder: (context, details, routeName) {
return Material(
child: Center(
child: Text('页面异常:${routeName ?? 'unknown'}'),
),
);
},
dumpFlutterErrorToConsole: true,
enableErrorWidget: true,
enablePlatformDispatcherError: true,
enableIsolateError: false,
);
runApp(const MyApp());
}
注意:
initXBErrorHandler内部有防重复初始化,重复调用只有第一次生效- 如果你要自定义异常页面,确保
errorWidgetBuilder返回的 Widget 不再抛异常 - 生产环境建议保留
reporter并接入你的监控平台
如果你使用的是 GetMaterialApp 或 CupertinoApp,请显式绑定:
navigatorKey: xbNavigatorKey
2. 创建页面
使用 XBPage(推荐用于页面)
import 'package:flutter/material.dart';
import 'package:xb_scaffold/xb_scaffold.dart';
class HomePage extends XBPage<HomePageVM> {
const HomePage({super.key});
@override
HomePageVM generateVM(BuildContext context) {
return HomePageVM(context: context);
}
@override
String setTitle(BuildContext context) => "首页";
@override
Widget buildPage(BuildContext context) {
final vm = vmOf(context);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text('计数器: ${vm.counter}'),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: vm.increment,
child: Text('增加'),
),
ElevatedButton(
onPressed: vm.decrement,
child: Text('减少'),
),
],
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => vm.showToast('Hello XB Scaffold!'),
child: Text('显示 Toast'),
),
],
),
);
}
// 自定义 AppBar(可选)
@override
List<Widget>? actions(BuildContext context) {
final vm = vmOf(context);
return [
IconButton(
icon: Icon(Icons.settings),
onPressed: vm.openSettings,
),
];
}
// 页面配置(可选)
@override
bool needSafeArea(BuildContext context) => true;
@override
bool needAdaptKeyboard(BuildContext context) => true;
}
class HomePageVM extends XBPageVM<HomePage> {
HomePageVM({required super.context});
int _counter = 0;
int get counter => _counter;
void increment() {
_counter++;
notify(); // 通知 UI 更新
}
void decrement() {
_counter--;
notify();
}
void showToast(String message) {
toast(message);
}
void openSettings() {
// todo
}
}
使用 XBWidget(用于组件)
class CounterWidget extends XBWidget<CounterWidgetVM> {
const CounterWidget({super.key});
@override
CounterWidgetVM generateVM(BuildContext context) {
return CounterWidgetVM(context: context);
}
@override
Widget buildWidget(BuildContext context) {
final vm = vmOf(context);
return Container(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text('计数: ${vm.count}'),
ElevatedButton(
onPressed: vm.increment,
child: Text('点击'),
),
],
),
);
}
}
class CounterWidgetVM extends XBVM<CounterWidget> {
CounterWidgetVM({required super.context});
int _count = 0;
int get count => _count;
void increment() {
_count++;
notify();
}
}
使用 XBVMLessWidget(无需自定义 VM)
class SimpleWidget extends XBVMLessWidget {
const SimpleWidget({super.key});
@override
Widget buildWidget(BuildContext context) {
return Container(
child: Text('简单组件'),
);
}
}
核心功能
VM 访问方式
XB Scaffold 提供了多种访问 VM 的方式:
1. 在 build 中直接拿 VM
@override
Widget buildPage(BuildContext context) {
final vm = vmOf(context);
return Text('计数: ${vm.counter}');
}
2. 使用 XBWidget 的方法
Widget _buildCounter(BuildContext context) {
final vm = context.vmOf<HomePageVM>(); // 不监听变化
final vmWatch = context.vmWatch<HomePageVM>(); // 监听变化
return ElevatedButton(
onPressed: vm.increment,
child: Text('计数: ${vmWatch.counter}'),
);
}
3. 使用 BuildContext 扩展(推荐)
class CounterDisplay extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 监听变化,会触发 rebuild
final vm = context.vmWatch<HomePageVM>();
return Text('计数: ${vm.counter}');
}
}
class CounterButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 不监听变化,不会触发 rebuild
final vm = context.vmOf<HomePageVM>();
return ElevatedButton(
onPressed: vm.increment,
child: Text('增加'),
);
}
}
4. 安全访问
Widget build(BuildContext context) {
final vm = context.vmOfOrNull<HomePageVM>();
if (vm == null) {
return Text('VM 不存在');
}
return Text('计数: ${vm.counter}');
}
主题管理
// 切换主题(索引对应初始化时的 themeConfigs)
XBThemeVM().changeTheme(1);
// 获取当前主题
final theme = XBThemeVM().theme;
// 在组件中使用主题颜色
Container(
color: colors.primary, // 使用主题色
child: Text('主题文本'),
)
// 扩展主题颜色
extension CustomColors on XBThemeColor {
Color get customBlue => Color(0xFF2196F3);
Color get customGreen => Color(0xFF4CAF50);
}
// 扩展主题字体族(默认 fontFamilies.def 为 null,即系统默认字体)
extension AppThemeFontFamily on XBThemeFontFamily {
String get pingFang => 'PingFang SC';
}
// 使用:Text('文本', style: TextStyle(fontFamily: fontFamilies.pingFang))
主题持久化(可选)
在 XBScaffold 上注入读写器即可自动记住用户选择的主题(无需引入额外依赖,存储后端由你决定):
// 例如配合 shared_preferences
final prefs = await SharedPreferences.getInstance();
XBScaffold(
themeConfigs: [...],
// 变更时自动调用
themeIndexWriter: (index) => prefs.setInt('theme_index', index),
// 启动初始化完成后自动回灌(返回 null 表示首次启动,保持默认主题)
themeIndexReader: () async => prefs.getInt('theme_index'),
child: ...,
)
说明:
- 回灌发生在主题注册完成之后,无时序问题;持久化的脏数据(越界/负数)会被静默忽略
changeTheme越界时:主题保持不变,debug 下抛AssertionError提前暴露问题(旧版会静默新建空主题导致页面拿到默认色)themegetter 读取未初始化的主题时不再静默新建空主题,而是回落到默认主题(index 0)并 assert 提示
Dialog 和弹窗
// 显示确认对话框
dialog(
title: '提示',
msg: '确定要删除吗?',
btnTitles: ['取消', '确定'],
onSelected: (index) {
if (index == 1) {
// 确认操作
}
},
);
// 显示输入对话框
dialogWidget(
XBDialogInput(
title: '输入',
placeholder: '请输入内容',
onDone: (text) {
print('输入的内容: $text');
},
),
);
// 显示 ActionSheet
actionSheet(
titles: ['拍照', '从相册选择'],
onSelected: (index) {
if (index == 0) {
// 拍照操作
} else {
// 选择照片操作
}
},
dismissTitle: '取消',
);
// 显示 Toast
toast('操作成功');
Loading 管理
class MyPageVM extends XBPageVM<MyPage> {
// 显示 Loading
void loadData() async {
showLoading(msg: '加载中...');
try {
// 执行异步操作
await Future.delayed(Duration(seconds: 2));
} finally {
hideLoading();
}
}
}
// 页面级 Loading 配置
@override
bool needLoading(BuildContext context) => true;
@override
bool needInitLoading(BuildContext context) => true; // 页面初始化时显示 Loading
事件总线
// 定义事件
class UserLoginEvent {
final String username;
UserLoginEvent(this.username);
}
// 在 VM 中监听事件
class HomePageVM extends XBPageVM<HomePage> {
@override
void didCreated() {
super.didCreated();
// 监听用户登录事件
listen<UserLoginEvent>((event) {
print('用户 ${event.username} 已登录');
// 处理登录后的逻辑
});
}
}
// 发送事件
XBEventBus.fire(UserLoginEvent('john_doe'));
弱注册语义(重要)
XBEventBus.addListener 为弱注册:Bus 对 listener 及其回调只持弱引用。
- VM 使用者无感知:
listen()内部会自动保管回调,VM 销毁时自动注销;即使忘记注销,VM 被回收后注册项也会随 GC 自动失效,不会泄漏 - 非 VM 使用者注意:回调须由 listener 自己持有(存入字段或作为其方法),否则回调可能先于 listener 被 GC 而收不到事件:
// ✅ 推荐:回调存入字段,listener 存活期间回调可达
class MyManager {
late final handler = (UserLoginEvent e) { ... };
MyManager() {
XBEventBus.addListener(this, handler);
}
}
// ❌ 避免临时匿名闭包且不持有注销句柄(会被 GC 回收)
- 需要强订阅(临时闭包、流式处理等场景)请用
XBEventBus.on<T>()获取流并自行管理StreamSubscription的取消 - 分发按事件
runtimeType精确匹配:注册父类型、fire 子类型实例时不会收到(请使用具体事件类型注册) XBEventBus.prune()可主动清理已被 GC 回收的注册项(通常无需手动调用)- 自定义 controller 请在初始化最早期调用
XBEventBus.initController(...),重复/时机错误会 assert 报错(旧setController已废弃)
工具类
定时器
final timer = XBTimer();
// 延时执行
timer.once(
duration: Duration(seconds: 2),
onTick: () {
print('2秒后执行');
},
);
// 重复执行
timer.repeat(
duration: Duration(seconds: 1),
onTick: () {
print('每秒执行一次');
},
);
// 取消定时器
timer.cancel();
防重复点击
final preventMultiTask = XBPreventMultiTask(intervalMilliseconds: 1000);
preventMultiTask.execute(
() {
submitData();
},
onError: () {
toast('请勿重复点击');
},
);
等待任务
final waitTask = XBWaitTask();
final result = await waitTask.execute<dynamic>(
task: () async {
await Future.wait([
loadUserData(),
loadConfigData(),
loadNotifications(),
]);
return true;
},
param: null,
milliseconds: 5000,
);
if (result == XBWaitTask.timeout) {
print('任务超时');
} else {
print('所有任务完成');
}
高级功能
悬浮头部列表
XBHoveringHeaderList(
itemCounts: sections.map((e) => e.items.length).toList(),
sectionHeaderBuild: (context, section) {
return Container(
height: 40,
color: Colors.grey[200],
child: Text('分组 $section'),
);
},
headerHeightForSection: (section) => 40,
itemBuilder: (context, indexPath, itemHeight) {
return ListTile(
title: Text('项目 ${indexPath.item}'),
);
},
itemHeightForIndexPath: (indexPath) => 56,
)
自定义组件
按钮组件
XBButtonText(
text: '点击按钮',
onTap: () {
print('按钮被点击');
},
backgroundColor: Colors.blue,
style: TextStyle(color: Colors.white),
borderRadius: 8,
enable: true, // 是否可点击
)
图片组件
XBImage(
'https://example.com/image.jpg',
width: 100,
height: 100,
placeholderWidget: CircularProgressIndicator(),
errWidget: Icon(Icons.error),
fit: BoxFit.cover,
)
页面配置选项
class MyPage extends XBPage<MyPageVM> {
// 是否需要安全区域
@override
bool needSafeArea(BuildContext context) => true;
// 是否需要适配键盘
@override
bool needAdaptKeyboard(BuildContext context) => true;
// 是否启用 Android 物理返回键
@override
bool onAndroidPhysicalBack(BuildContext context) => true;
// 是否启用 iOS 侧滑返回
@override
bool needIosGestureBack(BuildContext context) => true;
// 屏幕方向改变时是否重新构建
@override
bool needRebuildWhileOrientationChanged(BuildContext context) => false;
// 主题改变时是否重新构建
@override
bool needRebuildWhileAppThemeChanged(BuildContext context) => true;
// 页面背景色
@override
Color? backgroundColor(BuildContext context) => Colors.white;
// 导航栏背景色
@override
Color? navigationBarBGColor(BuildContext context) => Colors.blue;
// 导航栏标题颜色
@override
Color? navigationBarTitleColor(BuildContext context) => Colors.white;
}
最佳实践
1. VM 生命周期管理
VM 提供 mounted 属性(与 State.mounted 对齐):VM 未销毁且宿主 Widget 仍在树上时为 true。
任何异步回调(网络请求、Future.delayed、事件监听等)中,访问 context/state/widget 或刷新 UI 前,都应先检查它,避免页面销毁后使用悬空引用:
class MyPageVM extends XBPageVM<MyPage> {
void loadData() async {
try {
final user = await userRepository.getUser();
// 异步期间页面可能已被销毁
if (!mounted) return;
_user = user;
notify();
} catch (e) {
if (!mounted) return;
toast('加载失败');
}
}
}
class MyPageVM extends XBPageVM<MyPage> {
StreamSubscription? _subscription;
@override
void didCreated() {
super.didCreated();
// 页面创建时的初始化操作
_initData();
}
@override
void widgetDidBuilt() {
super.widgetDidBuilt();
// 页面构建完成后的操作
_startListening();
}
void _startListening() {
_subscription = someStream.listen((data) {
// 处理数据
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
2. 状态管理
class UserVM extends XBVM<UserWidget> {
UserState _state = UserState.loading;
UserState get state => _state;
User? _user;
User? get user => _user;
void loadUser() async {
_state = UserState.loading;
notify();
try {
_user = await userRepository.getUser();
_state = UserState.success;
} catch (e) {
_state = UserState.error;
}
notify();
}
}
enum UserState { loading, success, error }
3. 大页面性能优化:避免全量 rebuild
先说清楚默认机制:XBWidget 内部用 Consumer<T> 监听整个 VM,所以每次 vm.notify() 都会重建整个 buildWidget。
// 每次 notify(),buildWidget 里的所有 widget 都会重建
@override
Widget buildWidget(BuildContext context) {
return Column(
children: [
header, // ← 重建
hugeList, // ← 重建(贵)
bottomBar, // ← 重建
],
);
}
小页面这样完全没问题(Flutter 重建 widget 树本身很快,真正贵的是 layout/paint)。但大页面、列表重的页面,建议用下面的手段把刷新范围缩小。
手段一:拆分子 XBWidget(推荐,框架原生方式)
把高频刷新的区域提取为独立的 XBWidget,它有自己的 VM,notify() 只重建自己:
// 拆出的计数器组件,自己持有 VM
class CounterWidget extends XBWidget<CounterVM> {
@override
CounterVM generateVM(BuildContext context) => CounterVM(context: context);
@override
Widget buildWidget(BuildContext context) {
final vm = vmOf(context);
return Text('${vm.count}'); // 只重建这一小块
}
}
class CounterVM extends XBVM<CounterWidget> {
int count = 0;
void increment() {
count++;
notify(); // ← 只重建 CounterWidget,页面其他部分不动
}
}
手段二:context.select 只订阅特定字段
XBWidget 已经把 VM 放进了 Provider,子 Widget(普通的 StatelessWidget 即可)可以用 context.select 只依赖某个字段,字段不变就不重建:
// context.select 由 xb_scaffold 重新导出(SelectContext 扩展),
// 无需单独依赖 provider、无需额外 import
class PriceText extends StatelessWidget {
const PriceText({super.key});
@override
Widget build(BuildContext context) {
// 只依赖 price,其他字段变化不触发重建
final price = context.select<OrderVM, double>((vm) => vm.price);
return Text('¥$price');
}
}
注意:select 必须写在独立 Widget 的 build 里才有效。直接写在 buildWidget 里没有意义——父级 Consumer 已经订阅了整个 VM,每次 notify() 都会重建。
手段二的框架封装:XBSelect(推荐)
上面「独立 Widget + select + 值不变时跳过」的固定套路,框架已封装为 XBSelect,一步到位,不用再手写 Widget、也不用关心 const:
// 无需 import provider,无需单独定义 Widget
XBSelect<OrderVM, double>(
selector: (vm) => vm.price,
builder: (context, price) => Text('¥$price'),
)
工作机制:
- selector 返回值变化时,
XBSelect被 Provider 单独唤醒重建,其他字段 notify 不影响; - 值未变时返回上一次缓存的 child(同一实例),父级
Consumer波及重建时 Element 判定 identical 直接跳过整棵子树,等效于 const 的跳过语义。
注意事项:
- 重建判定分两层:内层由 Provider 的 select 完成(深比较,List/Map/Set 的内容变化——含原地修改
clear()+addAll()——都会触发重建;超大集合注意每次 notify 的深比较成本);外层缓存用==比较聚合值,推荐标量或 Dart 3 record; - builder 应只依赖传入的 value 与常量,值未变时会复用缓存的 child,读取其他会变化的外部数据可能拿到过期画面;
- 高频值(每帧/每秒变化)不要走 notify(),见手段三。
跨 VM / 多值订阅:XBSelectMulti
XBSelect 只能订阅单个 VM 的单个值;监听多个值(同一 VM 的多个字段、或跨多个 VM)时用 XBSelectMulti:selector 闭包内可调用任意多次 context.select,用 record 聚合结果,任何一个被 select 的值变化都会重建 builder:
XBSelectMulti<({int unread, String userName})>(
selector: (context) => (
unread: context.select<PageVM, int>((vm) => vm.unreadCount),
userName: context.select<UserVM, String>((vm) => vm.name),
),
builder: (context, v) => Text('${v.userName}: ${v.unread}'),
)
外层缓存逻辑与 XBSelect 相同:聚合值未变时返回缓存的 child(identical),无关 notify 时整块跳过。注意所有被 select 的 Provider 必须是该组件的祖先。
手段三:高频更新绕开 VM
倒计时、进度条、动画值这类每帧/每秒都变的状态,不要放进 VM 频繁 notify()——每次 notify 仍会重跑整个 buildWidget。用 VM 托管的 ValueNotifier 局部订阅,更新时只重建订阅该值的 builder,完全绕开 VM notify:
class OrderVM extends XBVM<OrderWidget> {
// createNotifier 创建的 notifier 由 VM 托管,dispose 时自动销毁,无需手动清理
late final secondsLeft = createNotifier<int>(60);
void startCountdown() {
Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
secondsLeft.value--; // ← 不调 notify(),只有订阅处重建
});
}
}
// UI 侧:只有这一小块每秒重建
ValueListenableBuilder<int>(
valueListenable: vm.secondsLeft,
builder: (context, seconds, _) => Text('$seconds s'),
)
完整示例见 example/lib/pages/xb_perf_optimize_demo.dart(含 XBSelect 与 createNotifier 的对照演示,配合 debugPrintRebuildDirtyWidgets = true 观察 rebuild 明细)。
怎么判断页面需要优化
在 debug 下打开重建统计,操作页面观察哪些 widget 被反复 rebuild:
// main() 中设置
debugPrintRebuildDirtyWidgets = true;
如果某次交互后日志里出现几百行 rebuild 记录,或列表 item 整体重建,就按上面三个手段处理。
常见问题
Q: 如何在子组件中访问父页面的 VM?
A: 使用 BuildContext 扩展:
class ChildWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final parentVM = context.vmOf<ParentPageVM>();
return Text(parentVM.someData);
}
}
Q: 如何自定义主题?
A: 使用扩展:
extension MyThemeColors on XBThemeColor {
Color get customPrimary => Color(0xFF1976D2);
Color get customAccent => Color(0xFFFF4081);
}
// 使用
Container(color: colors.customPrimary)
Q: 如何使用路由(push/pop)?
A: 路由能力已剥离到配套包 xb_simple_router,提供 Navigator 模式的全局 push/pop/replace/popToRoot、路由栈监听(xbSimpleRouteStackStream),以及可注入的 driver 抽象和 go_router 适配。
更新日志
查看 CHANGELOG.md 了解详细的版本更新信息。
许可证
本项目基于 MIT 许可证开源。查看 LICENSE 文件了解更多信息。
贡献
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目。
支持
如果这个项目对您有帮助,请给它一个 ⭐️!