diff --git a/airhub_app/CLAUDE.md b/airhub_app/CLAUDE.md new file mode 100644 index 0000000..51fd3c9 --- /dev/null +++ b/airhub_app/CLAUDE.md @@ -0,0 +1,294 @@ +# Airhub App 开发规范 + +> 所有新功能开发、代码修改、Bug 修复均需遵循本规范 + +--- + +## 一、项目架构 + +``` +lib/ +├── core/ # 基础设施层(全局共享) +│ ├── errors/ # 异常类 & Failure 类 +│ ├── network/ # ApiClient, TokenManager, ApiConfig +│ ├── router/ # go_router 路由配置 +│ └── services/ # 全局服务(LogCenter 等) +├── features/ # 功能模块(按业务领域划分) +│ ├── auth/ # 认证 +│ ├── badge/ # 电子吧唧 +│ ├── device/ # 设备管理 +│ ├── notification/ # 通知 +│ ├── spirit/ # 精灵/角色 +│ ├── user/ # 用户 +│ └── system/ # 系统(版本检查等) +├── pages/ # 独立页面(未归入 feature 的页面) +├── widgets/ # 全局可复用 Widget +├── theme/ # 主题、颜色、Design Tokens +├── services/ # 应用级服务(TTS、音乐生成等) +└── main.dart # 入口 +``` + +### Feature 模块内部结构 + +``` +features/xxx/ +├── data/ +│ ├── datasources/ # 远程/本地数据源 +│ ├── models/ # 数据模型(Freezed) +│ └── repositories/ # Repository 实现 +├── domain/ +│ └── repositories/ # Repository 接口 +└── presentation/ + ├── controllers/ # Riverpod 控制器 + ├── pages/ # 页面 + └── widgets/ # 功能专属 Widget +``` + +--- + +## 二、状态管理(Riverpod) + +### 规则 + +1. **使用 `@riverpod` 注解**声明 Provider,不手写 `StateNotifierProvider` 等 +2. Controller 使用 `AsyncValue` 管理异步状态 +3. Repository 返回 `Either`(fpdart),Controller 内 `fold` 处理 +4. **页面中优先使用 `ConsumerWidget`**(无状态);需要动画/本地状态时用 `ConsumerStatefulWidget` +5. **`ref.watch()` 用于 UI 绑定**(触发重建),**`ref.read()` 用于事件处理**(不触发重建) +6. **`ref.listen()` 用于副作用**(弹窗、导航、Toast),不触发 Widget 重建 + +### 示例 + +```dart +@riverpod +class MyController extends _$MyController { + @override + FutureOr build() async { + final repo = ref.watch(myRepositoryProvider); + final result = await repo.fetchData(); + return result.fold( + (failure) => throw failure, + (data) => MyState(data: data), + ); + } +} +``` + +--- + +## 三、网络层 + +### 规则 + +1. 所有 API 调用通过 `ApiClient`(Dio 封装),不直接使用 `http` 包或 `Dio()` +2. `ApiClient` 自动处理:Token 附加、401 自动刷新、错误上报 +3. 后端统一返回 `{code, message, data}`,`ApiClient._request()` 自动解析 +4. 异常类型:`ServerException`(code != 0)、`NetworkException`(连接失败) +5. **不在 Widget/Controller 中直接调用 ApiClient**,应通过 Repository 层 + +### 错误处理链 + +``` +DataSource (throw Exception) + → Repository (catch → Either) + → Controller (fold → AsyncValue) + → UI (AsyncValue.when: loading/data/error) +``` + +--- + +## 四、路由(go_router) + +### 规则 + +1. 所有路由定义在 `core/router/app_router.dart` +2. 使用 `context.go()` 进行声明式导航,避免 `Navigator.push()`(页面内二级跳转除外) +3. 登录态检查在 `redirect` 中统一处理 +4. 产品类型 → 路由映射在 `_productRoutes` Map 中维护 + +--- + +## 五、性能规范 + +### 内存管理 + +1. **所有 Stream 订阅必须存入变量,dispose 时取消** + ```dart + // ✅ 正确 + StreamSubscription? _positionSub; + _positionSub = stream.listen((data) { ... }); + + @override + void dispose() { + _positionSub?.cancel(); + super.dispose(); + } + + // ❌ 错误 — 无法取消,造成内存泄漏 + stream.listen((data) { ... }); + ``` + +2. **AnimationController、VideoPlayerController、AudioPlayer 等必须在 dispose 中释放** + +3. **页面离开时停止 BLE 扫描** + ```dart + @override + void dispose() { + FlutterBluePlus.stopScan(); + _scanSubscription?.cancel(); + super.dispose(); + } + ``` + +### Widget 重建优化 + +4. **能用 `const` 的地方必须加 `const`** + ```dart + // ✅ + const SizedBox(height: 16) + const EdgeInsets.all(16) + const Text('Hello') + + // ❌ + SizedBox(height: 16) + EdgeInsets.all(16) + ``` + +5. **动画区域用 `RepaintBoundary` 包裹**,防止全页面重绘 + ```dart + RepaintBoundary( + child: AnimatedGradientBackground(), + ) + ``` + +6. **`AnimatedBuilder` 必须使用 `child` 参数**,将不变的子树传入 child,不要在 builder 中重建 + ```dart + // ✅ + AnimatedBuilder( + animation: _controller, + child: const HeavyWidget(), // 只构建一次 + builder: (context, child) => Transform.rotate( + angle: _controller.value, + child: child, // 复用 + ), + ) + ``` + +### API 调用 + +7. **多个独立 API 调用使用 `Future.wait` 并行**,不要顺序 await + ```dart + // ✅ 并行 + final results = await Future.wait([ + api.get('/shelves/'), + api.get('/stories/'), + ]); + + // ❌ 顺序(慢 N 倍) + final shelves = await api.get('/shelves/'); + final stories = await api.get('/stories/'); + ``` + +8. **耗时计算(JSON 解析、图片处理)放到 Isolate** + ```dart + final result = await Isolate.run(() => jsonDecode(bigJson)); + ``` + +### 图片 + +9. **网络图片使用 `Image.network` + `fit: BoxFit.cover`**,不要手动指定 `cacheWidth/cacheHeight`(会导致显示异常) +10. **大量图片列表使用 `ListView.builder`**,不要 `ListView(children: [...])` + +--- + +## 六、构建配置 + +### Android Release + +- `build.gradle.kts` 中 release 配置: + - `isMinifyEnabled = true` — R8 代码压缩 + - `isShrinkResources = true` — 移除未使用资源 + - ProGuard 规则在 `proguard-rules.pro` 维护 +- 打包命令:`flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info` + +### iOS Release + +- 构建命令:`flutter build ipa --release --obfuscate --split-debug-info=./debug-info` + +--- + +## 七、命名规范 + +### 文件命名 + +| 类型 | 格式 | 示例 | +|------|------|------| +| 页面 | `xxx_page.dart` | `login_page.dart` | +| 控制器 | `xxx_controller.dart` | `auth_controller.dart` | +| 数据源 | `xxx_remote_data_source.dart` | `spirit_remote_data_source.dart` | +| 仓库实现 | `xxx_repository_impl.dart` | `auth_repository_impl.dart` | +| 仓库接口 | `xxx_repository.dart` | `auth_repository.dart` | +| 模型 | `xxx_model.dart` | `user_model.dart` | +| Widget | `xxx_widget.dart` 或直接描述 | `gradient_button.dart` | +| 生成文件 | `*.g.dart` / `*.freezed.dart` | 自动生成,不手动编辑 | + +### 类命名 + +- PascalCase:`AuthController`、`DeviceControlPage`、`ServerException` +- 私有前缀:`_buildHeader()`、`_positionSub` + +### 变量命名 + +- camelCase:`currentUser`、`isPlaying`、`_audioPlayer` +- Provider:`xxxProvider`(由 `@riverpod` 自动生成) + +--- + +## 八、主题系统 + +### 颜色 + +- 全局颜色定义在 `theme/app_colors.dart` +- 产品主题颜色在 `theme/product_theme.dart`(4 种产品类型) +- **不要在 Widget 中硬编码颜色值**,优先使用 `AppColors.xxx` 或 `Theme.of(context)` + +### 字体 + +- 主字体:DM Sans(正文)、Outfit(标题)、Press Start 2P(品牌像素风) +- 通过 `google_fonts` 包引用,**运行时自动下载并缓存** +- 本地字体:Inter(在 `pubspec.yaml` 中声明) + +### 间距 + +- 使用 `AppSpacing.xs/sm/md/lg/xl` 常量 +- 不要在 Widget 中硬编码间距数值 + +--- + +## 九、Git 规范 + +### 分支命名 + +- 功能分支:`fix/auto-YYYYMMDD-HHMMSS` +- 合并目标:`main` + +### Commit 规范 + +- `fix:` — Bug 修复 +- `feat:` — 新功能 +- `refactor:` — 重构(不改变行为) +- `chore:` — 构建配置、依赖更新等 + +--- + +## 十、检查清单(PR 提交前) + +- [ ] `dart analyze` 无 Error(Warning/Info 可接受) +- [ ] 所有 Stream 订阅已在 dispose 中取消 +- [ ] 所有 Controller/Player 已在 dispose 中释放 +- [ ] 多个独立 API 调用已用 `Future.wait` 并行化 +- [ ] 动画 Widget 使用了 `RepaintBoundary` 或 `AnimatedBuilder(child:)` +- [ ] 新增的 Widget 构造函数尽可能标记为 `const` +- [ ] 列表使用 `ListView.builder`,而非 `ListView(children:)` +- [ ] 未引入新的硬编码颜色值(使用 AppColors 或 theme) +- [ ] Android release 构建正常(minify + shrinkResources 不会崩溃) diff --git a/airhub_app/FLUTTER_OPTIMIZATION_GUIDE.md b/airhub_app/FLUTTER_OPTIMIZATION_GUIDE.md new file mode 100644 index 0000000..3a5c435 --- /dev/null +++ b/airhub_app/FLUTTER_OPTIMIZATION_GUIDE.md @@ -0,0 +1,703 @@ +# Flutter App 全面优化方案 + +> 针对 airhub_app 项目的从头到尾优化整改方案 +> 目标:减小包大小、降低发热、消除卡顿 + +--- + +## 一、项目现状分析 + +| 指标 | 当前值 | 问题 | +|------|--------|------| +| Flutter 版本 | 3.41.2 (Dart 3.11.0) | 较新,支持 Impeller | +| 代码规模 | 132 个 Dart 文件 / 28k 行 | 中等规模 | +| assets 总大小 | **~135MB** | **严重超标** | +| storybook_videos/ | **92MB** (11个mp4,最大48MB) | **主要元凶** | +| music/ | **15MB** (10个mp3) | 应该CDN下载 | +| story_covers/ | **7.4MB** (PNG图片) | 需压缩或远程加载 | +| 背景图 PNG | 单张 4.5-4.7MB | **未压缩的巨型PNG** | +| 字体文件 | 1.6MB (4个Inter字重) | 可裁剪子集 | +| google_fonts 依赖 | 存在 | 会下载额外字体 | +| Android release 配置 | 未启用 minifyEnabled | 缺少代码压缩 | +| ProGuard | 已引用但未启用 shrinkResources | 资源未裁剪 | +| iOS 构建 | 默认配置 | 未做符号裁剪优化 | + +--- + +## 二、包大小优化(预估可减少 60-70%) + +### 阶段 1:资源外迁(预估减少 110MB+ → 约 80% 的包大小) + +这是**最关键也是收益最大**的一步。 + +#### 1.1 视频资源迁移到 CDN / 服务器 +``` +当前: assets/www/storybook_videos/ → 92MB 全部打进包 +目标: 视频从服务端按需下载,本地缓存 +``` + +**实施方案:** +- 将所有 mp4 文件上传到后端服务器或 OSS/CDN +- App 端改为流式播放或首次使用时下载到本地缓存 +- 使用 `video_player` 的网络播放功能直接播放远程URL +- 实现下载进度显示和本地缓存管理 + +```dart +// 改为网络播放 +VideoPlayerController.networkUrl(Uri.parse('https://cdn.example.com/videos/magic_broom.mp4')) + +// 或按需下载到缓存 +final cacheDir = await getTemporaryDirectory(); +final file = File('${cacheDir.path}/magic_broom.mp4'); +if (!file.existsSync()) { + await dio.download(url, file.path, onReceiveProgress: (received, total) { + // 显示下载进度 + }); +} +``` + +#### 1.2 音频资源迁移 +``` +当前: assets/www/music/ → 15MB +目标: 音频按需下载或流式播放 +``` + +- `just_audio` 本身支持网络URL播放 +- 改为 `AudioSource.uri(Uri.parse('https://cdn.example.com/music/xxx.mp3'))` + +#### 1.3 故事封面图迁移 +``` +当前: assets/www/story_covers/ → 7.4MB +目标: 网络图片 + 本地缓存 +``` + +- 添加 `cached_network_image` 依赖 +- 封面图改为网络加载,自动缓存 + +#### 1.4 背景图压缩 +``` +当前: 首页背景2.png (4.7MB) + 首页底图.png (4.5MB) +目标: 压缩到 200-500KB +``` + +**方案A:转 WebP 格式** +```bash +# 安装 cwebp +brew install webp + +# 转换 (质量80,通常可减少 85%+) +cwebp -q 80 "assets/www/首页背景2.png" -o "assets/www/首页背景2.webp" +cwebp -q 80 "assets/www/首页底图.png" -o "assets/www/首页底图.webp" +``` + +**方案B:用 pngquant 压缩** +```bash +pngquant --quality=65-80 "assets/www/首页背景2.png" --output "assets/www/首页背景2.png" +``` + +预期效果:4.7MB → 300-500KB(减少 **90%**) + +### 阶段 2:构建配置优化(预估再减 30-40%) + +#### 2.1 Android 构建优化 + +**修改 `android/app/build.gradle.kts`:** +```kotlin +android { + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") // 换正式签名 + + // 启用代码压缩和资源裁剪 + isMinifyEnabled = true + isShrinkResources = true + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} +``` + +**构建命令使用 `--split-per-abi`:** +```bash +# 分架构打包,每个 APK 减少 30-40% +flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info + +# 或 App Bundle(Google Play 自动按设备裁剪) +flutter build appbundle --release --obfuscate --split-debug-info=./debug-info +``` + +| 参数 | 作用 | 预估收益 | +|------|------|----------| +| `--split-per-abi` | 按 CPU 架构分包 | 减少 30-40% | +| `--obfuscate` | 代码混淆 | 减少 5-10% | +| `--split-debug-info` | 分离调试符号 | 减少 5-10% | +| `minifyEnabled=true` | R8 代码压缩 | 减少 15-25% | +| `shrinkResources=true` | 移除未使用资源 | 减少 5-10% | + +#### 2.2 iOS 构建优化 + +**Xcode Build Settings 调整:** +- Strip Debug Symbols During Copy → YES +- Strip Linked Product → YES +- Dead Code Stripping → YES +- Optimization Level → -Oz (Smallest) + +**构建命令:** +```bash +flutter build ipa --release --obfuscate --split-debug-info=./debug-info +``` + +### 阶段 3:依赖瘦身 + +#### 3.1 审计当前依赖 + +| 依赖 | 大小影响 | 建议 | +|------|----------|------| +| `google_fonts` | 较大,会下载字体 | 已内嵌 Inter 字体,考虑移除 | +| `webview_flutter` | ~4MB 原生库 | 仅在需要时加载(deferred) | +| `video_player` | ~3-4MB | 考虑 deferred import | +| `image` (dart包) | 纯 Dart 图像处理 | 检查是否真的需要 | +| `http` | 与 `dio` 重复 | 统一用 dio,移除 http | +| `flutter_svg` | 中等 | 保留(SVG 比 PNG 小得多) | + +#### 3.2 移除 `google_fonts` + +你已经在 `pubspec.yaml` 中内嵌了 Inter 字体,`google_fonts` 是多余的: +```yaml +# 移除这个依赖 +# google_fonts: ^6.1.0 + +# 已有本地字体 +fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf + # ... +``` + +代码中改用 `TextStyle(fontFamily: 'Inter')` 替代 `GoogleFonts.inter()`。 + +#### 3.3 移除重复的 `http` 包 +```yaml +# 移除 - 已有 dio 做网络请求 +# http: ^1.2.0 +``` + +#### 3.4 字体子集化 + +当前 4 个 Inter 字体文件共 1.6MB。如果只需要中英文: +```bash +# 安装 fonttools +pip install fonttools brotli + +# 裁剪只保留需要的字符集(中文 + 英文 + 数字 + 标点) +pyftsubset Inter-Regular.ttf \ + --text-file=used_chars.txt \ + --output-file=Inter-Regular-subset.ttf + +# 通常可从 400KB → 50-80KB +``` + +--- + +## 三、性能优化:降低发热与 CPU 占用 + +### 3.1 Widget 重建优化(最重要) + +#### 使用 `const` 构造函数 +```dart +// ❌ 每次 build 都创建新实例 +Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16), // 每次创建新对象 + child: Text('Hello'), + ); +} + +// ✅ 编译期常量,不会触发重建 +Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: const Text('Hello'), + ); +} +``` + +**全项目检查:** 运行 `dart analyze` 查看 `prefer_const_constructors` 警告。 + +#### 使用 `RepaintBoundary` 隔离重绘区域 +```dart +// 对频繁更新的局部 Widget 包裹 RepaintBoundary +RepaintBoundary( + child: AnimatedWidget(...), // 只重绘这部分,不影响外部 +) +``` + +适用场景: +- 动画区域 +- 频繁刷新的倒计时/进度条 +- BLE 数据实时显示 + +#### 拆分 Widget,避免大范围重建 +```dart +// ❌ 整个页面一个大 build 方法 +class MyPage extends ConsumerWidget { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(someProvider); // 任何变化都重建整个页面 + return Scaffold( + body: Column(children: [Header(), Content(state), Footer()]), + ); + } +} + +// ✅ 拆成独立 Widget,只监听需要的部分 +class _Content extends ConsumerWidget { + Widget build(BuildContext context, WidgetRef ref) { + final data = ref.watch(someProvider.select((s) => s.data)); // 精准监听 + return ...; + } +} +``` + +### 3.2 Riverpod 状态管理优化 + +#### 使用 `select` 精准订阅 +```dart +// ❌ 监听整个 state,任何字段变化都重建 +final state = ref.watch(deviceProvider); + +// ✅ 只监听需要的字段 +final deviceName = ref.watch(deviceProvider.select((s) => s.name)); +final isConnected = ref.watch(deviceProvider.select((s) => s.isConnected)); +``` + +#### 区分 `watch` 和 `listen` +```dart +// watch: 用于 UI 显示的数据(需要重建) +final name = ref.watch(provider.select((s) => s.name)); + +// listen: 用于副作用(弹窗、导航、日志),不触发重建 +ref.listen(provider, (prev, next) { + if (next.hasError) showErrorDialog(context, next.error); +}); +``` + +### 3.3 BLE 通信优化(降低发热的关键) + +你的 app 大量使用蓝牙(`flutter_blue_plus`),这是发热的主要来源之一。 + +```dart +// ❌ 持续高频扫描 +FlutterBluePlus.startScan(timeout: Duration(seconds: 30)); + +// ✅ 扫到目标设备后立即停止 +FlutterBluePlus.startScan(timeout: Duration(seconds: 10)); +// 找到目标后 +FlutterBluePlus.stopScan(); + +// ❌ 不必要的频繁读取特征值 +Timer.periodic(Duration(milliseconds: 100), (_) => characteristic.read()); + +// ✅ 使用 notify 替代轮询 +await characteristic.setNotifyValue(true); +characteristic.onValueReceived.listen((value) { + // 只在有新数据时处理 +}); +``` + +**BLE 省电策略:** +- 连接成功后立即停止扫描 +- 使用 notification 而非 polling +- 非活跃页面暂停 BLE 数据监听 +- 合理设置连接间隔参数 + +### 3.4 图片与内存管理 + +#### 网络图片缓存 +```yaml +# 添加依赖 +dependencies: + cached_network_image: ^3.3.0 +``` + +```dart +// 使用缓存图片组件 +CachedNetworkImage( + imageUrl: imageUrl, + memCacheWidth: 300, // 限制内存中图片尺寸 + memCacheHeight: 300, + placeholder: (_, __) => const CircularProgressIndicator(), + errorWidget: (_, __, ___) => const Icon(Icons.error), +) +``` + +#### 大图降采样 +```dart +// ❌ 直接加载 4.7MB 的 PNG 到内存 +Image.asset('assets/www/首页背景2.png') + +// ✅ 指定缓存尺寸,降低内存占用 +Image.asset( + 'assets/www/首页背景2.webp', + cacheWidth: 750, // 按屏幕宽度限制 + cacheHeight: 1334, +) +``` + +#### 页面销毁时释放资源 +```dart +class _MyPageState extends State { + VideoPlayerController? _videoController; + + @override + void dispose() { + _videoController?.dispose(); // 必须释放! + super.dispose(); + } +} +``` + +### 3.5 Isolate 处理耗时任务 + +```dart +// 图片处理、数据解析等放到 Isolate +import 'package:flutter/foundation.dart'; + +// 简单任务用 compute +final result = await compute(processImage, imageData); + +// 复杂任务用 Isolate +Future processImageInIsolate(Uint8List rawData) async { + return await Isolate.run(() { + // 在独立线程处理图片 + return heavyImageProcessing(rawData); + }); +} +``` + +适用场景: +- BLE 接收的大量数据解析 +- 图片编解码(badge 图片处理) +- JSON 大数据解析 + +--- + +## 四、UI 流畅度优化(消除卡顿) + +### 4.1 列表优化 + +#### 使用 `ListView.builder` 而非 `ListView` +```dart +// ❌ 一次性创建所有子 Widget +ListView(children: items.map((e) => ItemWidget(e)).toList()) + +// ✅ 按需创建可见项 +ListView.builder( + itemCount: items.length, + itemExtent: 80, // 固定高度可提升滚动性能 + addAutomaticKeepAlives: false, // 不需要保活的列表关闭此项 + addRepaintBoundaries: true, + itemBuilder: (context, index) => ItemWidget(items[index]), +) +``` + +#### 图片列表优化 +```dart +ListView.builder( + cacheExtent: 500, // 预缓存区域 + itemBuilder: (context, index) { + return RepaintBoundary( + child: CachedNetworkImage( + imageUrl: items[index].imageUrl, + memCacheWidth: 200, // 限制内存图片大小 + ), + ); + }, +) +``` + +### 4.2 Impeller 渲染引擎(Flutter 3.41.2 默认启用) + +你的 Flutter 版本已默认使用 Impeller 渲染引擎,优势: +- **消除 Shader 编译卡顿**(Skia 的最大痛点) +- 更稳定的帧率 +- 更低的 GPU 内存占用 + +验证 Impeller 是否启用: +```bash +# iOS 默认启用,Android 3.22+ 默认启用 +flutter run --profile # 然后查看 DevTools +``` + +如果遇到兼容性问题可临时回退: +```bash +flutter run --no-enable-impeller +``` + +### 4.3 Shader 预热(如仍用 Skia) + +```dart +// 在 main.dart 中 +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 预编译 shader 缓存 + await ShaderWarmUp().execute(); + + runApp(const MyApp()); +} +``` + +SkSL 收集命令: +```bash +# 1. 收集 shader +flutter run --profile --cache-sksl --purge-persistent-cache + +# 2. 操作 app 中所有页面和动画 + +# 3. 按 M 导出 flutter_01.sksl.json + +# 4. 使用缓存构建 +flutter build apk --bundle-sksl-path flutter_01.sksl.json +``` + +### 4.4 页面切换优化 + +```dart +// go_router 中预加载关键页面 +GoRoute( + path: '/device-control', + pageBuilder: (context, state) => CustomTransitionPage( + transitionDuration: const Duration(milliseconds: 200), // 缩短动画时间 + child: const DeviceControlPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); // 用 Fade 替代 Slide + }, + ), +) +``` + +### 4.5 WebView 优化 + +```dart +// WebView 是重量级组件 +// 1. 延迟初始化 +late final WebViewController _controller; + +@override +void initState() { + super.initState(); + // 延迟到页面完全展示后再初始化 WebView + WidgetsBinding.instance.addPostFrameCallback((_) { + _initWebView(); + }); +} + +// 2. 页面销毁时清理 +@override +void dispose() { + _controller.clearCache(); + _controller.clearLocalStorage(); + super.dispose(); +} +``` + +--- + +## 五、工具与监控 + +### 5.1 包大小分析 +```bash +# 分析 APK 大小组成 +flutter build apk --analyze-size + +# 分析 iOS 包大小 +flutter build ipa --analyze-size +``` + +### 5.2 性能分析 +```bash +# Profile 模式运行(有性能数据但接近 release 性能) +flutter run --profile + +# 打开 DevTools +flutter pub global activate devtools +flutter pub global run devtools +``` + +### 5.3 检测无用代码和依赖 +```bash +# 检测未使用的依赖 +dart pub deps --no-dev + +# 分析代码问题 +dart analyze +``` + +### 5.4 Glance(APM 监控库) +```yaml +# 生产环境卡顿检测 +dependencies: + glance_flutter: ^0.x.x # 实验性 +``` + +--- + +## 六、实施优先级与预期效果 + +### P0 紧急(1-2天,收益巨大) + +| 任务 | 预期收益 | +|------|----------| +| 视频/音频迁移到 CDN | 包大小 -107MB (80%) | +| 背景图转 WebP 压缩 | 包大小 -8MB | +| 启用 `minifyEnabled` + `shrinkResources` | 包大小 -15-25% | +| `--split-per-abi` 分架构打包 | APK -30-40% | + +### P1 重要(3-5天) + +| 任务 | 预期收益 | +|------|----------| +| 移除 `google_fonts` 和 `http` 包 | 包大小 -2-4MB | +| 字体子集化 | 包大小 -1.2MB | +| `--obfuscate --split-debug-info` | 包大小 -5-10% | +| BLE 扫描/通信优化 | 降低发热 30-50% | +| 图片内存管理 (cacheWidth/Height) | 降低内存 40% | + +### P2 优化(1-2周) + +| 任务 | 预期收益 | +|------|----------| +| 全项目 const 构造函数审查 | 减少 Widget 重建 | +| Riverpod select 精准订阅 | 减少不必要重建 | +| 列表 ListView.builder 优化 | 滚动更流畅 | +| 耗时任务移入 Isolate | 消除主线程卡顿 | +| RepaintBoundary 隔离动画区域 | 减少重绘范围 | +| Deferred import (webview/video) | 减小初始加载 | + +### P3 持续(长期) + +| 任务 | 预期收益 | +|------|----------| +| 接入性能监控 (Glance/Sentry) | 线上问题感知 | +| 定期 `flutter build --analyze-size` | 包大小回归检测 | +| CI 集成包大小检查 | 防止包大小退化 | + +--- + +## 七、预期总体效果 + +| 指标 | 优化前 | 优化后(预估) | 改善 | +|------|--------|----------------|------| +| APK 大小 | ~150-180MB | ~25-35MB | **80%+** | +| IPA 大小 | ~200MB+ | ~40-60MB | **70%+** | +| 冷启动时间 | 3-5s | 1-2s | **60%** | +| 内存占用 | 较高 | 降低 40%+ | **40%+** | +| 手机发热 | 明显 | 温热 | **显著改善** | +| 滚动帧率 | 有掉帧 | 稳定 60fps | **流畅** | + +--- + +## 八、Impeller 渲染引擎性能数据 + +Flutter 3.41.2 已默认启用 Impeller(替代 Skia),关键性能数据: + +| 维度 | Skia | Impeller | 改善 | +|------|------|---------|------| +| Shader 编译 | 运行时 JIT(首次卡顿 20-200ms) | 构建时 AOT 预编译 | **消除 Shader 卡顿** | +| GPU 光栅化耗时 | 4.05ms | 2.81ms | **-30%** | +| 120Hz 帧率达标率 | 67.1% | 91.6% | **+36%** | +| 复杂场景掉帧率 | ~12% | ~1.5% | **-87%** | + +确认 Impeller 状态: +```bash +flutter run --profile # 然后在 DevTools 中查看渲染后端 +``` + +Android 持久化配置(`AndroidManifest.xml`): +```xml + +``` + +--- + +## 九、低端设备适配策略 + +针对不同档次手机动态降级: + +```dart +// 设备分级 +enum DeviceTier { low, mid, high } + +DeviceTier getDeviceTier() { + final memory = SysInfo.getTotalPhysicalMemory(); // 需原生获取 + if (memory < 3 * 1024 * 1024 * 1024) return DeviceTier.low; + if (memory < 6 * 1024 * 1024 * 1024) return DeviceTier.mid; + return DeviceTier.high; +} +``` + +| 策略 | 低端 | 中端 | 高端 | +|------|------|------|------| +| 动画 | 关闭粒子/复杂动画 | 简化动画 | 全特效 | +| 图片 | WebP 降分辨率 30% | 标准分辨率 | 高清 | +| BackdropFilter | 纯色替代 | 简化模糊 | 全高斯模糊 | +| 并发网络请求 | 限 2 个 | 限 4 个 | 不限制 | + +--- + +## 十、生产环境性能监控 + +| 工具 | 类型 | 用途 | +|------|------|------| +| [Glance](https://github.com/littleGnAl/glance) | 开源 APM | 采集卡顿时的堆栈,定位具体函数 | +| [flutter_smooth](https://github.com/fzyzcjy/flutter_smooth) | 开源库 | 即使 Widget 树极重也能达到 ~60fps | +| Firebase Performance | 云端 APM | 启动时间、网络延迟、自定义追踪 | +| Sentry | 错误+性能 | 崩溃报告+性能事务 | + +### 自动化性能测试(CI 集成) + +```dart +// integration_test/performance_test.dart +import 'package:integration_test/integration_test.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('scrolling performance', (tester) async { + await tester.pumpWidget(const MyApp()); + await binding.traceAction(() async { + await tester.fling(find.byType(ListView), const Offset(0, -500), 10000); + await tester.pumpAndSettle(); + }, reportKey: 'scrolling_timeline'); + }); +} +``` + +--- + +## 十一、参考资源 + +### 官方文档 +- [Flutter Performance Best Practices](https://docs.flutter.dev/perf/best-practices) +- [App Size Optimization](https://docs.flutter.dev/perf/app-size) +- [Impeller Rendering Engine](https://docs.flutter.dev/perf/impeller) +- [Deferred Components](https://docs.flutter.dev/perf/deferred-components) +- [Shader Compilation Jank](https://docs.flutter.dev/perf/shader) + +### 优秀开源项目参考 +- [flutter_deer](https://github.com/simplezhli/flutter_deer) — 完善的 Flutter 实践项目,代码规范 +- [Best-Flutter-UI-Templates](https://github.com/mitesh77/Best-Flutter-UI-Templates) — UI 模板合集 +- [Solido/awesome-flutter](https://github.com/Solido/awesome-flutter) — Flutter 最全资源汇总 + +### 工具 +- [Glance](https://pub.dev/packages/glance_flutter) — APM 卡顿检测 +- [TinyPNG](https://tinypng.com/) — PNG/WebP 压缩 +- [pngquant](https://pngquant.org/) — 批量 PNG 压缩 +- [pyftsubset](https://fonttools.readthedocs.io/) — 字体子集化 diff --git a/airhub_app/assets/fonts/DMSans-Italic-Variable.ttf b/airhub_app/assets/fonts/DMSans-Italic-Variable.ttf new file mode 100644 index 0000000..bf6819b Binary files /dev/null and b/airhub_app/assets/fonts/DMSans-Italic-Variable.ttf differ diff --git a/airhub_app/assets/fonts/DMSans-Variable.ttf b/airhub_app/assets/fonts/DMSans-Variable.ttf new file mode 100644 index 0000000..c672f98 Binary files /dev/null and b/airhub_app/assets/fonts/DMSans-Variable.ttf differ diff --git a/airhub_app/assets/fonts/Outfit-Variable.ttf b/airhub_app/assets/fonts/Outfit-Variable.ttf new file mode 100644 index 0000000..466d624 Binary files /dev/null and b/airhub_app/assets/fonts/Outfit-Variable.ttf differ diff --git a/airhub_app/assets/fonts/PressStart2P-Regular.ttf b/airhub_app/assets/fonts/PressStart2P-Regular.ttf new file mode 100644 index 0000000..39adf42 Binary files /dev/null and b/airhub_app/assets/fonts/PressStart2P-Regular.ttf differ diff --git a/airhub_app/lib/core/router/app_router.dart b/airhub_app/lib/core/router/app_router.dart index 1dc2bfb..453d5d4 100644 --- a/airhub_app/lib/core/router/app_router.dart +++ b/airhub_app/lib/core/router/app_router.dart @@ -1,7 +1,9 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../features/auth/presentation/pages/login_page.dart'; import '../../pages/bluetooth_page.dart'; @@ -21,6 +23,10 @@ import '../network/token_manager.dart'; part 'app_router.g.dart'; +const _lastRouteKey = 'last_business_route'; +const _lastProductTypeKey = 'last_product_type'; +const _validBusinessRoutes = {'/device-control', '/badge-control', '/badge-basic-control'}; + /// 产品代码 → 路由 + ProductType 的映射 const _productCodeRoutes = { 'KPBL-ON': (route: '/device-control', type: ProductType.capybara), @@ -43,29 +49,52 @@ GoRouter goRouter(Ref ref) { return '/login'; } if (hasToken && isLoginRoute) { - // 登录成功 → 跳到最近使用的设备业务页 + // 登录成功 → 获取已绑定设备列表 try { final dataSource = ref.read(deviceRemoteDataSourceProvider); final devices = await dataSource.getMyDevices(); debugPrint('[Router] 已绑定设备数: ${devices.length}'); + if (devices.isNotEmpty) { - // 按 last_online_at 降序,取最近使用的设备 + // 收集用户当前绑定的所有业务路由 + final boundRoutes = {}; + for (final d in devices) { + final dt = d.device.deviceType ?? d.device.deviceTypeInfo; + final code = dt?.productCode ?? ''; + final m = _productCodeRoutes[code]; + if (m != null) boundRoutes.add(m.route); + } + + // 优先恢复上次退出时的业务页面(需确认该设备仍在绑定中) + try { + final prefs = await SharedPreferences.getInstance(); + final savedRoute = prefs.getString(_lastRouteKey); + final savedProductType = prefs.getString(_lastProductTypeKey); + if (savedRoute != null && boundRoutes.contains(savedRoute)) { + debugPrint('[Router] 恢复上次页面: $savedRoute'); + if (savedProductType != null) { + final pt = ProductType.values.where((e) => e.name == savedProductType).firstOrNull; + if (pt != null) { + ref.read(currentProductTypeProvider.notifier).set(pt); + } + } + return savedRoute; + } + } catch (e) { + debugPrint('[Router] 读取上次页面失败: $e'); + } + + // Fallback:按设备最近使用时间跳转 devices.sort((a, b) { final ta = a.device.lastOnlineAt ?? ''; final tb = b.device.lastOnlineAt ?? ''; return tb.compareTo(ta); }); final recent = devices.first; - final dt = recent.device.deviceType; - final dti = recent.device.deviceTypeInfo; - debugPrint('[Router] 最近设备 sn=${recent.device.sn}'); - debugPrint('[Router] deviceType=$dt'); - debugPrint('[Router] deviceTypeInfo=$dti'); - final resolvedDt = dt ?? dti; + final resolvedDt = recent.device.deviceType ?? recent.device.deviceTypeInfo; final code = resolvedDt?.productCode ?? ''; - debugPrint('[Router] productCode=$code'); + debugPrint('[Router] 最近设备 sn=${recent.device.sn}, productCode=$code'); final mapping = _productCodeRoutes[code]; - debugPrint('[Router] mapping=$mapping → route=${mapping?.route}'); if (mapping != null) { ref.read(currentProductTypeProvider.notifier).set(mapping.type); return mapping.route; @@ -125,10 +154,43 @@ GoRouter goRouter(Ref ref) { final extra = state.extra as Map? ?? {}; return BadgeTransferPage( imageUrl: extra['imageUrl'] as String? ?? '', - imageBytes: extra['imageBytes'] as Uint8List?, + imageBytes: extra['imageBytes'] is Uint8List + ? extra['imageBytes'] as Uint8List + : extra['imageBytes'] is List + ? Uint8List.fromList(List.from(extra['imageBytes'] as List)) + : null, ); }, ), ], + observers: [_BusinessRouteObserver(ref)], ); } + +/// 监听路由变化,进入业务页时自动保存到 SharedPreferences +class _BusinessRouteObserver extends NavigatorObserver { + final Ref _ref; + _BusinessRouteObserver(this._ref); + + void _saveIfBusiness(Route? route) { + final name = route?.settings.name; + if (name != null && _validBusinessRoutes.contains(name)) { + final productType = _ref.read(currentProductTypeProvider); + debugPrint('[Router] 保存业务页: $name, productType=${productType.name}'); + SharedPreferences.getInstance().then((prefs) { + prefs.setString(_lastRouteKey, name); + prefs.setString(_lastProductTypeKey, productType.name); + }); + } + } + + @override + void didPush(Route route, Route? previousRoute) { + _saveIfBusiness(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _saveIfBusiness(newRoute); + } +} diff --git a/airhub_app/lib/features/auth/presentation/pages/login_page.dart b/airhub_app/lib/features/auth/presentation/pages/login_page.dart index 9d0465d..567093a 100644 --- a/airhub_app/lib/features/auth/presentation/pages/login_page.dart +++ b/airhub_app/lib/features/auth/presentation/pages/login_page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../../core/services/phone_auth_service.dart'; import '../../../../theme/app_colors.dart'; @@ -95,7 +94,7 @@ class _LoginPageState extends ConsumerState { // Title Text( '服务协议', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w600, color: const Color(0xFF374151), @@ -307,7 +306,7 @@ class _LoginPageState extends ConsumerState { // Logo — 匹配 HTML .login-logo Text( 'Airhub', - style: GoogleFonts.pressStart2p( + style: TextStyle(fontFamily: 'Press Start 2P', fontSize: 28, color: const Color(0xFF6366F1), // 靛蓝 letterSpacing: 2, @@ -463,7 +462,7 @@ class _LoginPageState extends ConsumerState { children: [ Text( '欢迎使用 Airhub', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 32, fontWeight: FontWeight.w700, color: const Color(0xFF6B5B95), @@ -728,7 +727,7 @@ class _AgreementContentPage extends StatelessWidget { ), title: Text( title, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w600, color: const Color(0xFF374151), diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart index 7bd9819..3f72c46 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart @@ -1,14 +1,11 @@ -import 'dart:convert'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import '../../../../core/network/api_config.dart'; +import '../../../../core/network/api_client.dart'; import '../../../../pages/profile/profile_page.dart'; import '../../../../theme/product_theme.dart'; import '../../../../widgets/animated_gradient_background.dart'; @@ -53,25 +50,16 @@ class _BadgeBasicControlPageState extends ConsumerState Future _loadLastImage() async { try { - final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString('access_token'); - final resp = await http.get( - Uri.parse('${ApiConfig.fullBaseUrl}/badge/history/'), - headers: {if (token != null) 'Authorization': 'Bearer $token'}, - ).timeout(const Duration(seconds: 10)); - - if (resp.statusCode == 200) { - final body = jsonDecode(resp.body) as Map; - final data = body['data'] as Map? ?? {}; - final images = (data['images'] as List? ?? []) - .cast>() - .where((img) => - img['generation_status'] == 'completed' && - (img['image_url'] as String?)?.isNotEmpty == true) - .toList(); - if (images.isNotEmpty && mounted) { - setState(() => _lastImageUrl = images.first['image_url'] as String); - } + final apiClient = ref.read(apiClientProvider); + final data = await apiClient.get('/badge/history/'); + final images = ((data as Map)['images'] as List? ?? []) + .cast>() + .where((img) => + img['generation_status'] == 'completed' && + (img['image_url'] as String?)?.isNotEmpty == true) + .toList(); + if (images.isNotEmpty && mounted) { + setState(() => _lastImageUrl = images.first['image_url'] as String); } } catch (_) {} if (mounted) setState(() => _loading = false); diff --git a/airhub_app/lib/features/device/presentation/controllers/device_controller.dart b/airhub_app/lib/features/device/presentation/controllers/device_controller.dart index 4653412..b3dca3d 100644 --- a/airhub_app/lib/features/device/presentation/controllers/device_controller.dart +++ b/airhub_app/lib/features/device/presentation/controllers/device_controller.dart @@ -23,12 +23,11 @@ class DeviceController extends _$DeviceController { Future bindDevice(String sn, {int? spiritId}) async { final repository = ref.read(deviceRepositoryProvider); final result = await repository.bindDevice(sn, spiritId: spiritId); - if (!ref.mounted) return '组件已卸载'; + if (!ref.mounted) return null; // 组件已卸载,绑定请求已发出,视为成功 return result.fold( (failure) => failure.message, (bindingId) { - if (!ref.mounted) return '组件已卸载'; - ref.invalidateSelf(); + if (ref.mounted) ref.invalidateSelf(); return null; }, ); diff --git a/airhub_app/lib/pages/bluetooth_page.dart b/airhub_app/lib/pages/bluetooth_page.dart index 967ffa9..bbafa24 100644 --- a/airhub_app/lib/pages/bluetooth_page.dart +++ b/airhub_app/lib/pages/bluetooth_page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/services/ble_provisioning_service.dart'; @@ -290,23 +289,18 @@ class _BluetoothPageState extends ConsumerState debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName'); } catch (e) { debugPrint('[Bluetooth] queryByMac 失败($mac): $e'); - // API 查询失败时,用 BLE 名作为 fallback 也显示出来 if (!mounted) return; - final bleDevice = _pendingBleDevices[mac]; - setState(() { - if (!_devices.any((d) => d.macAddress == mac)) { - _devices.add(MockDevice( - sn: '', - name: '${_airhubPrefix}设备', - macAddress: mac, - type: DeviceType.plush, - hasAI: true, - bleDevice: bleDevice, - )); - } - _isSearching = false; - }); + // 查询失败 → 停止扫描,提示用户 + setState(() => _isSearching = false); try { await FlutterBluePlus.stopScan(); } catch (_) {} + _macInfoCache.remove(mac); // 移除占位,允许重新扫描时再查 + showGlassDialog( + context: context, + title: '设备查询失败', + description: '无法验证设备信息,请检查网络后重试。', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); } } @@ -445,6 +439,16 @@ class _BluetoothPageState extends ConsumerState } } catch (e) { debugPrint('[Bluetooth] bindDevice 异常: $e'); + if (!mounted) return; + setState(() => _isConnecting = false); + showGlassDialog( + context: context, + title: '绑定失败', + description: '$e', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; } if (!mounted) return; setState(() => _isConnecting = false); @@ -565,7 +569,13 @@ class _BluetoothPageState extends ConsumerState children: [ // 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border GestureDetector( - onTap: () => context.pop(), + onTap: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/'); + } + }, child: Container( width: 40, height: 40, @@ -586,7 +596,7 @@ class _BluetoothPageState extends ConsumerState child: Text( '搜索设备', textAlign: TextAlign.center, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -658,7 +668,7 @@ class _BluetoothPageState extends ConsumerState height: 120, placeholderBuilder: (_) => Text( '?', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 48, fontWeight: FontWeight.w700, color: const Color(0xFFF59E0B), // Amber color per HTML @@ -827,7 +837,7 @@ class _BluetoothPageState extends ConsumerState // 设备名称 Text( device.name, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w600, color: device.isBoundByOther @@ -903,7 +913,13 @@ class _BluetoothPageState extends ConsumerState children: [ // 取消按钮 - HTML: frosted glass with border GestureDetector( - onTap: () => context.pop(), + onTap: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/'); + } + }, child: ClipRRect( borderRadius: BorderRadius.circular(25), child: Container( diff --git a/airhub_app/lib/pages/device_control_page.dart b/airhub_app/lib/pages/device_control_page.dart index 643882f..3f2d529 100644 --- a/airhub_app/lib/pages/device_control_page.dart +++ b/airhub_app/lib/pages/device_control_page.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/network/api_client.dart'; import 'story_detail_page.dart'; @@ -303,7 +302,7 @@ class _DeviceControlPageState extends ConsumerState const SizedBox(width: 8), Text( statusText, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF4B5563), @@ -327,7 +326,7 @@ class _DeviceControlPageState extends ConsumerState const SizedBox(width: 4), Text( batteryText, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF4B5563), diff --git a/airhub_app/lib/pages/home_page.dart b/airhub_app/lib/pages/home_page.dart index e9e497d..a341b15 100644 --- a/airhub_app/lib/pages/home_page.dart +++ b/airhub_app/lib/pages/home_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; import '../widgets/animated_gradient_background.dart'; @@ -88,7 +87,7 @@ class _HomePageState extends State child: Text( 'Airhub', // Use Press Start 2P pixel font per HTML CSS - style: GoogleFonts.pressStart2p( + style: TextStyle(fontFamily: 'Press Start 2P', fontSize: 28, color: const Color(0xFF6366F1), // 靛蓝 letterSpacing: 2, @@ -239,7 +238,7 @@ class _HomePageState extends State child: Center( child: Text( '立即连接', - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white, diff --git a/airhub_app/lib/pages/login_page.dart b/airhub_app/lib/pages/login_page.dart index 8f530e8..a2dae74 100644 --- a/airhub_app/lib/pages/login_page.dart +++ b/airhub_app/lib/pages/login_page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; import '../widgets/gradient_button.dart'; @@ -58,7 +57,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { // Title Text( '服务协议', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -326,7 +325,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { padding: const EdgeInsets.only(top: 32), child: Text( 'Airhub', - style: GoogleFonts.pressStart2p( + style: TextStyle(fontFamily: 'Press Start 2P', fontSize: 28, color: const Color(0xFF6366F1), letterSpacing: 2, @@ -502,7 +501,7 @@ class _LoginPageState extends State with TickerProviderStateMixin { // Heading - font-size: 32px, font-weight: 700 Text( '欢迎使用 Airhub', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 32, fontWeight: FontWeight.w700, color: const Color(0xFF6B5B95), diff --git a/airhub_app/lib/pages/music_creation_page.dart b/airhub_app/lib/pages/music_creation_page.dart index 26bea18..940a187 100644 --- a/airhub_app/lib/pages/music_creation_page.dart +++ b/airhub_app/lib/pages/music_creation_page.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show PlatformException; -import 'package:google_fonts/google_fonts.dart'; import 'package:just_audio/just_audio.dart'; import '../services/music_generation_service.dart'; import '../widgets/animated_gradient_background.dart'; @@ -801,7 +800,7 @@ class _MusicCreationPageState extends State child: Text( '灵感电台', textAlign: TextAlign.center, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -1108,7 +1107,7 @@ class _MusicCreationPageState extends State track.lyrics.isNotEmpty ? _cleanLyrics(track.lyrics) : '生成音乐后\n点我看歌词', - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12, height: 1.6, color: track.lyrics.isNotEmpty @@ -1170,7 +1169,7 @@ class _MusicCreationPageState extends State ), child: Text( bubbleText, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12.5, fontWeight: FontWeight.w500, color: const Color(0xFF6B4423), @@ -1227,7 +1226,7 @@ class _MusicCreationPageState extends State child: Text( _currentTime, textAlign: TextAlign.center, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12, color: const Color(0xFF6B7280), fontFeatures: const [FontFeature.tabularFigures()], @@ -1277,7 +1276,7 @@ class _MusicCreationPageState extends State child: Text( _totalTime, textAlign: TextAlign.center, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12, color: const Color(0xFF6B7280), fontFeatures: const [FontFeature.tabularFigures()], @@ -1434,7 +1433,7 @@ class _MusicCreationPageState extends State children: [ Text( mood['title'] as String, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 14, fontWeight: isActive ? FontWeight.w700 : FontWeight.w600, color: isActive @@ -1447,7 +1446,7 @@ class _MusicCreationPageState extends State const SizedBox(height: 2), Text( mood['desc'] as String, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 11, color: isActive ? const Color(0xFF6B7280) @@ -1908,7 +1907,7 @@ class _InputModalContent extends StatelessWidget { children: [ Text( '自由创作', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 16, fontWeight: FontWeight.w600, color: const Color(0xFF374151), @@ -1935,7 +1934,7 @@ class _InputModalContent extends StatelessWidget { alignment: Alignment.centerLeft, child: Text( '描述你想要的音乐氛围、场景或情绪', - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12, color: const Color(0xFF9CA3AF), ), @@ -1950,11 +1949,11 @@ class _InputModalContent extends StatelessWidget { controller: controller, minLines: 4, maxLines: 6, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 14, color: const Color(0xFF374151)), decoration: InputDecoration( hintText: '例如:水豚在雨中等公交,心情却很平静...', - hintStyle: GoogleFonts.dmSans( + hintStyle: TextStyle(fontFamily: 'DM Sans', fontSize: 14, color: const Color(0xFF9CA3AF)), filled: true, fillColor: Colors.black.withOpacity(0.03), @@ -2099,7 +2098,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent> children: [ Text( '我的唱片架', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 15, fontWeight: FontWeight.w600, color: const Color(0xFF374151), @@ -2266,7 +2265,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent> Flexible( child: Text( track.title, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 12, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500, color: isCurrent diff --git a/airhub_app/lib/pages/product_selection_page.dart b/airhub_app/lib/pages/product_selection_page.dart index 9122da5..90b961f 100644 --- a/airhub_app/lib/pages/product_selection_page.dart +++ b/airhub_app/lib/pages/product_selection_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../widgets/animated_gradient_background.dart'; import '../features/device/presentation/controllers/device_controller.dart'; @@ -233,7 +232,7 @@ class _ProductSelectionPageState extends ConsumerState { children: [ Text( '选择产品', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -365,7 +364,7 @@ class _ProductCard extends StatelessWidget { children: [ Text( product['name'], - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 19, fontWeight: FontWeight.bold, color: Colors.white, diff --git a/airhub_app/lib/pages/settings_page.dart b/airhub_app/lib/pages/settings_page.dart index 94111ee..70ca55e 100644 --- a/airhub_app/lib/pages/settings_page.dart +++ b/airhub_app/lib/pages/settings_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../widgets/glass_dialog.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/ios_toast.dart'; @@ -100,7 +99,7 @@ class _SettingsPageState extends ConsumerState { ), Text( '设置', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 16, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), diff --git a/airhub_app/lib/pages/wifi_config_page.dart b/airhub_app/lib/pages/wifi_config_page.dart index d5aaa0b..36797fa 100644 --- a/airhub_app/lib/pages/wifi_config_page.dart +++ b/airhub_app/lib/pages/wifi_config_page.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/services/ble_provisioning_service.dart'; +import '../features/device/data/datasources/device_remote_data_source.dart'; import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; @@ -134,6 +134,30 @@ class _WifiConfigPageState extends ConsumerState if (_isBinding) return; setState(() => _isBinding = true); + // 绑定前再次检查设备归属(防止 BLE 扫描时检查遗漏) + final mac = _deviceInfo['mac'] as String? ?? ''; + if (mac.isNotEmpty) { + try { + final dataSource = ref.read(deviceRemoteDataSourceProvider); + final macData = await dataSource.queryByMac(mac); + final bindStatus = macData['bind_status'] as String? ?? 'unbound'; + if (bindStatus == 'bound_by_other') { + if (!mounted) return; + setState(() => _isBinding = false); + showGlassDialog( + context: context, + title: '无法绑定', + description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; + } + } catch (e) { + debugPrint('[WiFi Config] 检查设备归属失败: $e'); + } + } + final sn = _deviceInfo['sn'] as String? ?? ''; if (sn.isNotEmpty) { try { @@ -153,6 +177,16 @@ class _WifiConfigPageState extends ConsumerState } } catch (e) { debugPrint('[WiFi Config] bindDevice 异常: $e'); + if (!mounted) return; + setState(() => _isBinding = false); + showGlassDialog( + context: context, + title: '绑定失败', + description: '$e', + confirmText: '确定', + onConfirm: () => Navigator.of(context).pop(), + ); + return; } } if (!mounted) return; @@ -322,7 +356,7 @@ class _WifiConfigPageState extends ConsumerState child: Text( 'WiFi配网', textAlign: TextAlign.center, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), @@ -386,7 +420,7 @@ class _WifiConfigPageState extends ConsumerState ), Text( '选择WiFi网络', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.bold, color: const Color(0xFF1F2937), @@ -528,7 +562,7 @@ class _WifiConfigPageState extends ConsumerState ), Text( _selectedWifiSsid, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.bold, color: const Color(0xFF1F2937), @@ -596,7 +630,7 @@ class _WifiConfigPageState extends ConsumerState ), Text( '正在配网...', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.bold, color: const Color(0xFF1F2937), @@ -712,7 +746,7 @@ class _WifiConfigPageState extends ConsumerState const SizedBox(height: 24), Text( '配网成功!', - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 24, fontWeight: FontWeight.bold, color: const Color(0xFF1F2937), diff --git a/airhub_app/lib/theme/app_theme.dart b/airhub_app/lib/theme/app_theme.dart index 3cae2e8..46f4100 100644 --- a/airhub_app/lib/theme/app_theme.dart +++ b/airhub_app/lib/theme/app_theme.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; class AppTheme { static ThemeData get lightTheme { // Base text theme with DM Sans (PRD: 正文/UI 字体) - final baseTextTheme = GoogleFonts.dmSansTextTheme(const TextTheme( + final baseTextTheme = const TextTheme( // h1 / Large Headings displayLarge: TextStyle( color: AppColors.textPrimary, @@ -40,19 +39,19 @@ class AppTheme { fontWeight: FontWeight.w600, letterSpacing: 0.5, ), - )); + ).apply(fontFamily: 'DM Sans'); // Apply Outfit to heading styles (PRD: 标题/Display 字体) final textTheme = baseTextTheme.copyWith( - displayLarge: GoogleFonts.outfit(textStyle: baseTextTheme.displayLarge), - displayMedium: GoogleFonts.outfit(textStyle: baseTextTheme.displayMedium), - displaySmall: GoogleFonts.outfit(textStyle: baseTextTheme.displaySmall), - headlineLarge: GoogleFonts.outfit(textStyle: baseTextTheme.headlineLarge), - headlineMedium: GoogleFonts.outfit(textStyle: baseTextTheme.headlineMedium), - headlineSmall: GoogleFonts.outfit(textStyle: baseTextTheme.headlineSmall), - titleLarge: GoogleFonts.outfit(textStyle: baseTextTheme.titleLarge), - titleMedium: GoogleFonts.outfit(textStyle: baseTextTheme.titleMedium), - titleSmall: GoogleFonts.outfit(textStyle: baseTextTheme.titleSmall), + displayLarge: baseTextTheme.displayLarge?.copyWith(fontFamily: 'Outfit'), + displayMedium: baseTextTheme.displayMedium?.copyWith(fontFamily: 'Outfit'), + displaySmall: baseTextTheme.displaySmall?.copyWith(fontFamily: 'Outfit'), + headlineLarge: baseTextTheme.headlineLarge?.copyWith(fontFamily: 'Outfit'), + headlineMedium: baseTextTheme.headlineMedium?.copyWith(fontFamily: 'Outfit'), + headlineSmall: baseTextTheme.headlineSmall?.copyWith(fontFamily: 'Outfit'), + titleLarge: baseTextTheme.titleLarge?.copyWith(fontFamily: 'Outfit'), + titleMedium: baseTextTheme.titleMedium?.copyWith(fontFamily: 'Outfit'), + titleSmall: baseTextTheme.titleSmall?.copyWith(fontFamily: 'Outfit'), ); return ThemeData( @@ -67,7 +66,7 @@ class AppTheme { background: AppColors.bgBase, ), // PRD: DM Sans 为默认正文字体,回退到系统字体 - fontFamily: GoogleFonts.dmSans().fontFamily, + fontFamily: 'DM Sans', fontFamilyFallback: const [ 'Roboto', 'PingFang SC', diff --git a/airhub_app/lib/theme/design_tokens.dart b/airhub_app/lib/theme/design_tokens.dart index 302cf09..cb139de 100644 --- a/airhub_app/lib/theme/design_tokens.dart +++ b/airhub_app/lib/theme/design_tokens.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; /// 颜色定义 - 精确还原 Profile PRD class AppColors { @@ -111,77 +110,77 @@ class AppColors { /// 字体样式 - PRD规范: Outfit(标题) + DM Sans(正文) + Press Start 2P(Logo) class AppTextStyles { // 页面标题 — 统一规范: 17px w600 #1F2937 - static final TextStyle title = GoogleFonts.outfit( + static final TextStyle title = TextStyle(fontFamily: 'Outfit', fontSize: 17, fontWeight: FontWeight.w600, color: const Color(0xFF1F2937), ); // User Name → Outfit (heading/display) - static final TextStyle userName = GoogleFonts.outfit( + static final TextStyle userName = TextStyle(fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ); // User ID → DM Sans (body) - static final TextStyle userId = GoogleFonts.dmSans( + static final TextStyle userId = TextStyle(fontFamily: 'DM Sans', fontSize: 13, fontWeight: FontWeight.w400, color: AppColors.textSecondary, ); // Menu Text → DM Sans (body/UI) - static final TextStyle menuText = GoogleFonts.dmSans( + static final TextStyle menuText = TextStyle(fontFamily: 'DM Sans', fontSize: 16, fontWeight: FontWeight.w400, color: AppColors.textPrimary, ); // Badge Text → DM Sans (small UI) - static final TextStyle badge = GoogleFonts.dmSans( + static final TextStyle badge = TextStyle(fontFamily: 'DM Sans', fontSize: 10, fontWeight: FontWeight.w400, color: Colors.white, ); // Modal Title → Outfit (heading) - static final TextStyle modalTitle = GoogleFonts.outfit( + static final TextStyle modalTitle = TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ); // Book specific styles → Outfit (heading) - static final TextStyle bookTitle = GoogleFonts.outfit( + static final TextStyle bookTitle = TextStyle(fontFamily: 'Outfit', fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ); // Book count → DM Sans (body) - static final TextStyle bookCount = GoogleFonts.dmSans( + static final TextStyle bookCount = TextStyle(fontFamily: 'DM Sans', fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ); // Slot title → DM Sans (small UI) - static final TextStyle slotTitle = GoogleFonts.dmSans( + static final TextStyle slotTitle = TextStyle(fontFamily: 'DM Sans', fontSize: 10, color: Colors.white, fontWeight: FontWeight.w400, ); // PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7 - static final TextStyle emptyPlus = GoogleFonts.dmSans( + static final TextStyle emptyPlus = TextStyle(fontFamily: 'DM Sans', fontSize: 24, fontWeight: FontWeight.w300, color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity ); // Button text → DM Sans (UI) - static final TextStyle createStoryBtn = GoogleFonts.dmSans( + static final TextStyle createStoryBtn = TextStyle(fontFamily: 'DM Sans', fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white, diff --git a/airhub_app/lib/widgets/glass_dialog.dart b/airhub_app/lib/widgets/glass_dialog.dart index 339578c..e8392af 100644 --- a/airhub_app/lib/widgets/glass_dialog.dart +++ b/airhub_app/lib/widgets/glass_dialog.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'gradient_button.dart'; import '../theme/app_colors.dart' as appclr; @@ -55,7 +54,7 @@ class GlassDialog extends StatelessWidget { // Title Text( title, - style: GoogleFonts.outfit( + style: TextStyle(fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.w600, color: const Color(0xFF4B2404), diff --git a/airhub_app/lib/widgets/gradient_button.dart b/airhub_app/lib/widgets/gradient_button.dart index 1c8f577..c3d3565 100644 --- a/airhub_app/lib/widgets/gradient_button.dart +++ b/airhub_app/lib/widgets/gradient_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; import '../theme/product_theme.dart'; @@ -125,7 +124,7 @@ class GradientButton extends StatelessWidget { ) : Text( text, - style: GoogleFonts.dmSans( + style: TextStyle(fontFamily: 'DM Sans', fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white, diff --git a/airhub_app/pubspec.lock b/airhub_app/pubspec.lock index 324c64d..dde25db 100644 --- a/airhub_app/pubspec.lock +++ b/airhub_app/pubspec.lock @@ -524,14 +524,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 - url: "https://pub.dev" - source: hosted - version: "6.3.3" graphs: dependency: transitive description: diff --git a/airhub_app/pubspec.yaml b/airhub_app/pubspec.yaml index b7fa786..3b13a1c 100644 --- a/airhub_app/pubspec.yaml +++ b/airhub_app/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: # Existing dependencies webview_flutter: ^4.4.2 permission_handler: ^11.0.0 - google_fonts: ^6.1.0 + # google_fonts removed — local fonts used instead flutter_blue_plus: ^1.31.0 flutter_svg: ^2.0.9 image_picker: ^1.2.1 @@ -90,6 +90,17 @@ flutter: weight: 600 - asset: assets/fonts/Inter-Bold.ttf weight: 700 + - family: DM Sans + fonts: + - asset: assets/fonts/DMSans-Variable.ttf + - asset: assets/fonts/DMSans-Italic-Variable.ttf + style: italic + - family: Outfit + fonts: + - asset: assets/fonts/Outfit-Variable.ttf + - family: Press Start 2P + fonts: + - asset: assets/fonts/PressStart2P-Regular.ttf # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images