提示信息
# 同伴 App · Flutter 完整开发计划 v2.1
> 本文档面向独立开发者,基于《同伴 App 前端开发规范 v5》整理。
> 涵盖:项目初始化 → SDK 接入顺序 → 基础设施搭建 → 业务开发 → 发布准备。
> 每步均附 **与 AI 交流的提示词模板**,可直接复制使用。
> **v2 更新**:全面同步新依赖版本,含 Riverpod 3.x、freezed 3.x、go_router 17.x、freerasp 7.x、envied 1.x、talker 5.x 等 breaking changes。
> **v2.1 更新**:`remixicon ^1.0.0` → `^1.4.0`;`image_gallery_saver ^2.0.3` 替换为 `image_gallery_saver_plus ^4.0.1`(原包已停止维护)。
---
## 目录
1. [如何与 AI 交流](#一如何与-ai-交流)
2. [阶段一:项目初始化](#阶段一项目初始化-day-1)
3. [阶段二:核心 UI 体系](#阶段二核心-ui-体系-day-2)
4. [阶段三:网络层 + Storage](#阶段三网络层--storage-day-3)
5. [阶段四:Adapter 层骨架](#阶段四adapter-层骨架-day-4)
6. [阶段五:启动流程 + 隐私合规](#阶段五启动流程--隐私合规-day-5)
7. [阶段六:推送原生层](#阶段六推送原生层-day-6)
8. [阶段七:认证体系](#阶段七认证体系-day-7)
9. [阶段八:支付体系](#阶段八支付体系-day-8)
10. [阶段九:IM 接入](#阶段九im-接入-day-9)
11. [阶段十:业务 Feature 开发范式](#阶段十业务-feature-开发范式-day-10)
12. [阶段十一:发布准备](#阶段十一发布准备)
13. [iOS Podfile 通用完整模板](#ios-podfile-通用完整模板)
14. [SDK 接入顺序总表](#sdk-接入顺序总表)
15. [常见编译错误速查](#常见编译错误速查)
---
## 一、如何与 AI 交流
### 1.1 每次新对话必须粘贴的上下文
**新对话开头永远先粘贴 `frontend.md` 全文**,写 UI 页面时再额外粘贴 `frontend_ui.md` 全文。没有这两个文件,AI 不知道你的规范,会产生大量不符合要求的代码。
### 1.2 标准提问模板
```
【规范】(粘贴 frontend.md 全文)
(写 UI 时额外粘贴 frontend_ui.md 全文)
【需求】实现 [feature 名称] 的 [具体功能]
【接口】
- 路径:POST /xxx/yyy
- 请求:{ field1: string, field2: int }
- 响应 data:{ id: string, name: string }
【UI 描述】
(简要描述页面结构、交互逻辑)
【特殊要求】
(如:需要乐观更新 / 特殊错误码处理 / 某个组件的四态)
```
### 1.3 常用追问指令
| 场景 | 追问方式 |
|------|----------|
| 代码不符合规范 | "请检查是否符合规范第 XX 节,重新生成" |
| Riverpod Ref 类型错误 | "请检查所有 provider 函数参数,将 XxxRef 改为通用 Ref(Riverpod 3.x)" |
| go_router redirect 报错 | "请更新 redirect 回调为 async FutureOr<String?> 签名(go_router 17.x)" |
| 缺少某个文件 | "还需要帮我生成 `xxx_api.dart` 和 `xxx_real_api.dart`" |
| 需要补全 | "请补全 AsyncValue 的三态处理" |
| 生成 build_runner 代码后 | "帮我检查生成的代码是否可以通过 flutter analyze" |
### 1.4 强制规则提醒 AI
每次涉及以下场景,明确告知 AI:
- **颜色** → "颜色必须从 AppColors 取,禁止 Colors.xxx 字面量"
- **路由跳转** → "跳转必须通过 NavigationService,禁止 Navigator.push"
- **SDK 调用** → "必须通过 Adapter 层,禁止页面直接 import SDK"
- **新建 Feature** → "先告诉我 shared.dart 里有哪些可复用的基础组件"
- **Riverpod provider** → "所有 provider 函数参数类型必须是 `Ref`,不是 `XxxRef`"
- **go_router redirect** → "必须是异步签名 `FutureOr<String?>`"
---
## 阶段一:项目初始化(Day 1)
### Step 1.1 创建项目
```bash
flutter create --platforms=ios,android tongban_app
cd tongban_app
```
删除 `lib/main.dart` 中的默认计数器代码,只保留入口结构。
### Step 1.2 写入全量依赖
打开 `pubspec.yaml`,将以下内容完整替换 `environment`、`dependencies` 和 `dev_dependencies` 三节:
```yaml
environment:
sdk: '>=3.8.0 <4.0.0' # ⚠️ 必须 >=3.8.0,json_serializable 6.11+ 要求
dependencies:
flutter:
sdk: flutter
# 核心架构(三件套必须版本一致)
flutter_riverpod: ^3.3.1
hooks_riverpod: ^3.3.1
riverpod_annotation: ^4.0.2
flutter_hooks: ^0.21.3
go_router: ^17.1.0
dio: ^5.4.0
mmkv: ^2.0.1
# UI 与动画
shimmer: ^3.0.0
easy_refresh: ^3.3.4
flutter_animate: ^4.5.0
cached_network_image: ^3.4.1
flutter_cache_manager: any
flutter_svg: ^2.0.9
qr_flutter: ^4.1.0
flutter_widget_from_html: ^0.17.1
lottie: ^3.0.0
# 图标
remixicon: ^1.4.0
# 主题与敏捷布局
flex_color_scheme: ^8.4.0
velocity_x: ^4.0.0
# 数据模型
freezed_annotation: ^3.1.0
json_annotation: ^4.11.0
# 工具
connectivity_plus: ^7.0.0
path_provider: ^2.1.2
sqflite: ^2.3.0
path: ^1.9.0
permission_handler: ^12.0.1
intl: ^0.20.2
uuid: ^4.3.3
crypto: ^3.0.7
url_launcher: ^6.3.2
screen_protector: ^1.5.1
image_gallery_saver_plus: ^4.0.1
# 媒体
wechat_assets_picker: ^10.1.1
wechat_camera_picker: ^4.4.0
image_cropper: ^11.0.0
flutter_image_compress: ^2.4.0
record: ^6.2.0
just_audio: ^0.10.5
# 业务 SDK
flutter_openim_sdk: ^3.8.3
fluwx: ^5.7.5 # 6.0 还在 preview,稳定版保持 5.x
tobias: ^5.3.4 # 6.0 还在 preview,稳定版保持 5.x
in_app_purchase: ^3.2.3
# umeng_common_sdk: ^1.3.0 # 已暂停接入,待合规评估后重新启用
# 安全
freerasp: ^7.5.0
device_info_plus: ^12.3.0
flutter_udid: ^4.1.2
envied: ^1.3.3
# 调试
talker_flutter: ^5.1.15
talker_dio_logger: ^5.1.15
talker_riverpod_logger: ^5.1.14
# WebView
flutter_inappwebview: ^6.1.5
# 国际化
flutter_localizations:
sdk: flutter
# 隐私
app_tracking_transparency: ^2.0.6+1
dev_dependencies:
flutter_test:
sdk: flutter
riverpod_generator: ^4.0.3 # 必须与 riverpod_annotation 版本配套
build_runner: ^2.12.2
freezed: ^3.2.5
json_serializable: ^6.11.2
envied_generator: ^1.3.3
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/images/icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/images/icon_foreground.png"
web:
generate: false
windows:
generate: false
flutter:
uses-material-design: true
generate: true # 启用 flutter gen-l10n
assets:
- assets/lottie/
- assets/images/
- assets/icons/
tobias:
url_scheme: com.xiaopai.match
```
然后执行:
```bash
flutter pub get
```
> **注意**:`riverpod_annotation ^4.0.2`、`hooks_riverpod ^3.3.1`、`riverpod_generator ^4.0.3` 三包版本必须严格对应,升级时一起升。
### Step 1.3 创建完整目录结构
```bash
mkdir -p lib/core/adapters
mkdir -p lib/core/network
mkdir -p lib/core/storage
mkdir -p lib/core/config
mkdir -p lib/core/utils
mkdir -p lib/core/models
mkdir -p lib/core/optimistic
mkdir -p lib/core/cache
mkdir -p lib/core/privacy
mkdir -p lib/core/preload
mkdir -p lib/core/migration
mkdir -p lib/features
mkdir -p lib/theme
mkdir -p lib/routes
mkdir -p lib/widgets
mkdir -p lib/icons
mkdir -p lib/l10n
mkdir -p assets/lottie
mkdir -p assets/images
mkdir -p assets/icons
touch lib/shared.dart
touch lib/app.dart
touch lib/env.dart
```
### Step 1.4 配置多环境与密钥管理
在根目录创建两个环境文件(**必须立即加入 `.gitignore`**):
**`env.dev.json`**(示例结构,填入真实测试环境值):
```json
{
"isProd": "false",
"API_BASE_URL_DEV": "https://api-dev.yourapp.com",
"APP_KEY_IOS_DEV": "your_ios_dev_key",
"APP_KEY_ANDROID_DEV": "your_android_dev_key",
"APP_SECRET_DEV": "your_dev_secret",
"WECHAT_APP_ID": "wx_your_wechat_id",
"WECHAT_UNIVERSAL_LINK": "https://www.yourapp.com/app/",
"ALIPAY_URL_SCHEME": "com.yourcompany.app",
"ALICLOUD_PUSH_APP_KEY_IOS": "your_push_key_ios",
"ALICLOUD_PUSH_APP_SECRET_IOS": "your_push_secret_ios"
// "UMENG_APP_KEY_IOS": "your_umeng_key_ios",
// "UMENG_APP_KEY_ANDROID": "your_umeng_key_android"
}
```
**`env.prod.json`**(填入正式环境值,结构相同,值换正式的):
```json
{
"isProd": "true",
"API_BASE_URL_PROD": "https://api.yourapp.com",
"APP_KEY_IOS_PROD": "your_ios_prod_key",
"APP_KEY_ANDROID_PROD": "your_android_prod_key",
"APP_SECRET_PROD": "your_prod_secret",
"WECHAT_APP_ID": "wx_your_wechat_id",
"WECHAT_UNIVERSAL_LINK": "https://www.yourapp.com/app/",
"ALIPAY_URL_SCHEME": "com.yourcompany.app",
"ALICLOUD_PUSH_APP_KEY_IOS": "your_push_key_ios",
"ALICLOUD_PUSH_APP_SECRET_IOS": "your_push_secret_ios"
// "UMENG_APP_KEY_IOS": "your_umeng_key_ios",
// "UMENG_APP_KEY_ANDROID": "your_umeng_key_android"
}
```
> 应用配置与系统状态由 `/app/start` 接口动态下发存入 MMKV,禁止提前硬编码。
在 `.gitignore` 中追加:
```
env.dev.json
env.prod.json
lib/env.g.dart
.env
```
**`lib/env.dart`**(envied 1.x 写法,`obfuscate: true` 是核心):
```dart
import 'package:envied/envied.dart';
part 'env.g.dart';
// ⚠️ envied 1.x:path 直接指向 JSON 配置文件
@Envied(path: 'env.dev.json', obfuscate: true)
abstract class Env {
@EnviedField(varName: 'APP_SECRET_DEV')
static final String appSecret = _Env.appSecret;
@EnviedField(varName: 'WECHAT_APP_ID')
static final String wechatAppId = _Env.wechatAppId;
}
```
执行代码生成(⚠️ `flutter pub run` 已废弃,改用 `dart run`):
```bash
dart run build_runner build --delete-conflicting-outputs
```
**l10n 配置**,在根目录创建 `l10n.yaml`:
```yaml
arb-dir: lib/l10n
template-arb-file: app_zh.arb
output-localization-file: app_localizations.dart
```
在 `lib/l10n/` 下创建 `app_zh.arb`:
```json
{
"@@locale": "zh",
"appName": "同伴"
}
```
---
## 阶段二:核心 UI 体系(Day 2)
### Step 2.1 主题系统
创建 `lib/theme/` 下的四个文件:
**`app_colors.dart`** — 所有颜色常量,基于设计稿提取
```dart
import 'package:flutter/material.dart';
class AppColors {
AppColors._();
static const Color primary = Color(0xFF6366F1);
static const Color primaryLight = Color(0xFFEEF2FF);
static const Color secondary = Color(0xFF8B5CF6);
static const Color error = Color(0xFFEF4444);
static const Color warning = Color(0xFFF59E0B);
static const Color success = Color(0xFF10B981);
static const Color textPrimary = Color(0xFF111827);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textHint = Color(0xFF9CA3AF);
static const Color bgPrimary = Color(0xFFFFFFFF);
static const Color bgSecondary = Color(0xFFF9FAFB);
static const Color divider = Color(0xFFE5E7EB);
// 语音消息固定用中性色
static const Color voiceMessage = Color(0xFF6B7280);
}
```
**`app_text_styles.dart`**、**`app_spacing.dart`**、**`app_radius.dart`** — 同理,从设计稿提取所有字号、间距、圆角常量。
**`app_theme.dart`** — 基于 flex_color_scheme 构建亮/暗主题:
```dart
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData get light => FlexThemeData.light(
colors: FlexSchemeColor.from(primary: AppColors.primary),
useMaterial3: true,
);
static ThemeData get dark => FlexThemeData.dark(
colors: FlexSchemeColor.from(primary: AppColors.primary),
useMaterial3: true,
);
}
```
### Step 2.2 图标语义层
创建 `lib/icons/app_icons.dart`:
```dart
import 'package:remixicon/remixicon.dart';
/// 所有页面必须通过 AppIcons 引用图标,禁止直接使用 Remix 或其他 IconData
class AppIcons {
AppIcons._();
// Tab Bar
static const home = Remix.home_4_line;
static const homeActive = Remix.home_4_fill;
static const message = Remix.message_3_line;
static const messageActive = Remix.message_3_fill;
static const discover = Remix.compass_3_line;
static const discoverActive = Remix.compass_3_fill;
static const profile = Remix.user_3_line;
static const profileActive = Remix.user_3_fill;
// 通用操作
static const back = Remix.arrow_left_line;
static const close = Remix.close_line;
static const more = Remix.more_2_line;
static const add = Remix.add_line;
static const search = Remix.search_2_line;
static const send = Remix.send_plane_fill;
static const camera = Remix.camera_line;
static const image = Remix.image_line;
static const voice = Remix.mic_line;
static const voiceActive = Remix.mic_fill;
static const settings = Remix.settings_3_line;
static const notification = Remix.notification_3_line;
static const like = Remix.heart_line;
static const likeActive = Remix.heart_fill;
static const share = Remix.share_line;
static const edit = Remix.edit_line;
static const delete = Remix.delete_bin_line;
static const check = Remix.check_line;
static const warning = Remix.error_warning_line;
}
```
### Step 2.3 基础 Widget 组件库
> 📖 完整组件规范与用法见:[交互组件整理报告.md](./交互组件整理报告.md)
> 🎮 组件演示:Debug 模式登录页点击「组件展示」,或 `context.push(AppRoutes.showcase)`
所有组件已实现并可用,通过 `import 'package:tongban_app/shared.dart';` 统一引入:
| 文件 | 组件 | 说明 |
|---|---|---|
| `widgets/primary_action_button.dart` | `PrimaryActionButton` | 主操作按钮,高度 48dp,内置 Loading 防抖 |
| `widgets/app_capsule_button.dart` | `AppCapsuleButton` | 社交胶囊按钮(关注/已关注/回关) |
| `widgets/app_dialog.dart` | `AppDialog` | 确认弹窗 + 底部表单(`show` / `showSheet`) |
| `widgets/app_toast.dart` | `AppToast` | Overlay 式 Toast,4 种类型,顶部滑入 |
| `widgets/app_empty_state.dart` | `AppEmptyState` | 空状态,8 种预设类型 + custom |
| `widgets/app_skeleton.dart` | `AppSkeleton` | 骨架屏;**推荐 `AppSkeleton.wrap(enabled:,child:)`** 自动生成(基于 skeletonizer ^1.4.2) |
| `widgets/app_loading.dart` | `AppLoading` | 局部/全屏 Loading |
| `widgets/app_list_view.dart` | `AppListView<T>` | 下拉刷新 + 上拉加载一站式列表 |
| `widgets/custom_avatar.dart` | `CustomAvatar` | 头像(5 种尺寸,自动失败占位) |
| `widgets/app_tab_bar.dart` | `AppTabBar` | 标签页(选中 12sp/粗体) |
| `widgets/nav/app_nav_bar.dart` | `AppNavBar` | 通用导航栏(44pt 高,iOS HIG) |
| `widgets/nav/app_transparent_nav_bar.dart` | `AppTransparentNavBar` | 透明渐变导航栏 |
| `widgets/nav/app_search_nav_bar.dart` | `AppSearchNavBar` | 搜索导航栏 |
| `widgets/nav/app_chat_nav_bar.dart` | `AppChatNavBar` | 聊天页导航栏 |
| `widgets/nav/app_bottom_nav_bar.dart` | `AppBottomNavBar` | 底部 TabBar(Material 3,64dp) |
**主路由架构**:登录后导向 `/main` → `MainShellPage`(三 Tab:首页 / 消息 / 我的)
### Step 2.4 初始化 `app.dart`
```dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'theme/app_theme.dart';
import 'routes/app_router.dart';
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: '同伴',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
routerConfig: router,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
}
}
```
### Step 2.5 配置路由骨架
创建 `lib/routes/app_routes.dart`(路由名常量)和 `lib/routes/app_router.dart`(go_router 配置骨架):
```dart
// app_routes.dart
class AppRoutes {
static const splash = '/';
static const privacy = '/privacy';
static const login = '/login';
static const home = '/home';
static const banned = '/banned';
}
```
```dart
// app_router.dart
// ⚠️ go_router 17.x + Riverpod 3.x 新写法
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:talker_flutter/talker_flutter.dart';
part 'app_router.g.dart';
@riverpod
GoRouter router(Ref ref) { // ← Riverpod 3.x: 通用 Ref,不是 RouterRef
return GoRouter(
initialLocation: AppRoutes.splash,
observers: [TalkerRouteObserver(talker)],
redirect: (BuildContext context, GoRouterState state) async {
// ← go_router 17.x: redirect 必须是 async / FutureOr<String?>
// 路由守卫逻辑在阶段五完善
return null;
},
routes: [
// 路由在各阶段逐步添加
],
);
}
```
创建 `lib/routes/navigation_service.dart`(全局跳转封装):
```dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class NavigationService {
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
static BuildContext get context => navigatorKey.currentContext!;
static void go(String route, {Object? extra}) =>
context.go(route, extra: extra);
static void push(String route, {Object? extra}) =>
context.push(route, extra: extra);
static void pop<T>([T? result]) => context.pop(result);
}
```
### Step 2.6 初始化 `main.dart`
```dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mmkv/mmkv.dart';
import 'package:talker_flutter/talker_flutter.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger.dart';
import 'app.dart';
final talker = TalkerFlutter.init();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await MMKV.initialize(); // 必须是第一个 await
runApp(
ProviderScope(
// ⚠️ talker 5.x: 参数改为命名参数 talker:
observers: [TalkerRiverpodObserver(talker: talker)],
child: const App(),
),
);
}
```
---
## 阶段三:网络层 + Storage(Day 3)
### Step 3.1 Storage 层
**`lib/core/storage/storage_keys.dart`** — 所有 MMKV key 常量,禁止在代码中散落字符串:
```dart
class StorageKeys {
StorageKeys._();
static const String accessToken = 'auth:access_token';
static const String refreshToken = 'auth:refresh_token';
static const String userId = 'auth:user_id';
static const String privacyAgreed = 'privacy:agreed';
static const String themeMode = 'theme:mode';
static const String appStartConfig = 'app:start_config';
static const String adConfig = 'app:ad_config';
static const String sdkConfig = 'app:sdk_config';
static const String deviceRiskFlag = 'security:risk_flag';
static const String attRequested = 'att:requested'; // iOS ATT
}
```
**`lib/core/storage/app_storage.dart`** — 加密模式 MMKV 封装:
```dart
import 'package:mmkv/mmkv.dart';
class AppStorage {
static late MMKV _mmkv;
static Future<void> init() async {
_mmkv = MMKV('tongban_secure', cryptKey: 'your_encrypt_key');
}
static String? getString(String key) => _mmkv.decodeString(key);
static void setString(String key, String value) =>
_mmkv.encodeString(key, value);
static bool? getBool(String key) => _mmkv.decodeBool(key);
static void setBool(String key, bool value) =>
_mmkv.encodeBool(key, value);
static void remove(String key) => _mmkv.removeValue(key);
}
```
> **注意**:`AppStorage.init()` 在 `main()` 中 `MMKV.initialize()` 之后立即调用。
### Step 3.2 AppConfig
**`lib/core/config/app_config.dart`**:
```dart
class AppConfig {
static const bool isProd =
bool.fromEnvironment('isProd', defaultValue: false);
static String get apiBaseUrl => isProd
? const String.fromEnvironment('API_BASE_URL_PROD')
: const String.fromEnvironment('API_BASE_URL_DEV');
static String get appKeyIos => isProd
? const String.fromEnvironment('APP_KEY_IOS_PROD')
: const String.fromEnvironment('APP_KEY_IOS_DEV');
static String get appKeyAndroid => isProd
? const String.fromEnvironment('APP_KEY_ANDROID_PROD')
: const String.fromEnvironment('APP_KEY_ANDROID_DEV');
}
```
### Step 3.3 网络层
创建以下文件(**按顺序**):
**`lib/core/models/app_api_error.dart`**(freezed 3.x,语法与 2.x 兼容):
```dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'app_api_error.freezed.dart';
@freezed
class AppApiError with _$AppApiError implements Exception {
const factory AppApiError({
required int code,
required String message,
Object? data,
}) = _AppApiError;
const AppApiError._();
bool get isNetworkError => code == -1;
bool get canWatchAd => code == 3007;
bool get isSignalLimit => code == 3008;
bool get isTokenExpired => code == 1005;
bool get isForceLogout => code == 1006;
bool get isBanned => code == 1008;
}
```
**`lib/core/network/api_interceptor.dart`** — 响应拦截器,统一处理全部错误码:
```dart
// 处理规范 31.1 的完整错误码矩阵
// 1005 → 静默刷新 Token 后重试(需配合 TokenRefreshInterceptor)
// 1006 → 清除 Token + 强退登录页
// 1008 → 跳封禁页,传 data.ban_info
// 其余 → handler.reject(AppApiError)
```
**`lib/core/network/auth_interceptor.dart`** — 请求拦截器,统一注入所有必须 Header:
```
X-App-Key / X-Timestamp / X-Nonce / X-Sign / X-Device-Id
X-OS / X-App-Version / X-Language
(安全检测状态:X-Device-Risk-Flag: ["safe"] / ["unverified"] / ["risk_ids"...])
```
签名算法:`MD5(appKey + timestamp + nonce + sortedParams + appSecret)`
**`lib/core/network/token_interceptor.dart`** — Token 刷新逻辑单独拆出,防止循环调用。刷新成功后自动重试原请求,刷新失败则触发 forceLogout。
**`lib/core/network/dio_client.dart`** — 组装主 Dio 实例:
```dart
// 主 Dio:挂 AuthInterceptor + ApiInterceptor + TokenInterceptor + talker_dio_logger
// 纯净 Dio:专用于 OSS 预签名 URL 下载,不带任何自定义 Header
```
**`lib/core/network/network_provider.dart`** — ⚠️ Riverpod 3.x:用通用 `Ref` 暴露两个 Dio 实例:
```dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'network_provider.g.dart';
@riverpod
Dio mainDio(Ref ref) { // ← Riverpod 3.x: 通用 Ref,不是 MainDioRef
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.interceptors.addAll([
AuthInterceptor(ref),
ApiInterceptor(ref),
TokenInterceptor(ref),
TalkerDioLogger(talker: talker),
]);
return dio;
}
@riverpod
Dio pureDio(Ref ref) { // ← 通用 Ref,不是 PureDioRef
// 纯净 Dio,专用于 OSS 预签名 URL,不带 Authorization 头
return Dio();
}
```
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现 lib/core/network/api_interceptor.dart。
要求:
1. 处理规范 31.1 的完整错误码矩阵
2. code=1005 触发静默 Token 刷新并重试原请求,刷新期间并发请求需排队等待
3. code=1006 调用 AuthRepository.forceLogout()
4. code=1008 调用 NavigationService.go(AppRoutes.banned, extra: body['data'])
5. 所有后端错误统一 reject AppApiError 异常
6. 其余非 0 code 一律 reject AppApiError(code, msg, data)
7. 使用 AppApiError freezed 模型(freezed 3.x)
8. 所有 @riverpod 函数参数使用通用 Ref(Riverpod 3.x 要求)
```
---
## 阶段四:Adapter 层骨架(Day 4)
所有 Adapter 遵循统一结构:接口抽象类 + 默认实现类,业务层只依赖接口。
### Step 4.1 按复杂度从低到高逐个实现
**1. `haptic_adapter.dart`**(最简单,先练手)
```dart
abstract class HapticAdapter {
Future<void> lightImpact();
Future<void> mediumImpact();
Future<void> heavyImpact();
Future<void> vibrate();
}
class RealHapticAdapter implements HapticAdapter {
// 使用 HapticFeedback(平台原生 API,无需额外 SDK)
}
```
**2. `webview_adapter.dart`**(隐私政策展示依赖它)
```dart
abstract class WebViewAdapter {
Future<void> openUrl(BuildContext context, String url, {String? title});
Future<void> openPrivacyPolicy(BuildContext context);
Future<void> openUserAgreement(BuildContext context);
}
// 实现基于 flutter_inappwebview ^6.1.5
```
**3. `security_adapter.dart`**(影响所有请求 Header)
```dart
abstract class SecurityAdapter {
Future<void> initialize();
bool get isHighRisk;
bool get isRooted;
bool get isJailbroken;
bool get isEmulator;
}
// ⚠️ freerasp 7.x:start() 必须加超时兜底,防止启动阻塞导致黑屏
class RealSecurityAdapter implements SecurityAdapter {
@override
Future<void> initialize() async {
try {
await Talsec.instance.start(config).timeout(
const Duration(seconds: 5),
onTimeout: () => talker.warning('freerasp start timeout'),
);
} catch (e, st) {
talker.error('freerasp init error', e, st);
}
}
// 检测结果写入 AppStorage(StorageKeys.deviceRiskFlag)
// 禁止本地弹窗,只静默标记
}
```
**4. `analytics_adapter.dart`**(友盟,注意合规)
<!-- 友盟统计 AnalyticsAdapter (已暂停接入)
```dart
abstract class AnalyticsAdapter {
// 注意:此 init() 只能在用户同意隐私协议后才能调用
Future<void> init();
void logEvent(String event, {Map<String, dynamic>? params});
void setPageManual(); // setPageCollectionModeManual()
}
// 实现基于 umeng_common_sdk ^1.3.0
```
-->
**5. `push_adapter.dart`**(阿里云推送,Dart 侧 MethodChannel)
```dart
abstract class PushAdapter {
Future<void> initialize();
Future<String?> getRegistrationId();
Stream<Map<String, dynamic>> get onMessageReceived;
Future<void> bindAccount(String userId);
Future<void> unbindAccount();
}
```
**6. `push_adapter.dart`**(仅保留推送适配)
```dart
abstract class AdsAdapter {
Future<void> initialize({required String csjAppId, required String gdtAppId});
Future<void> showSplashAd({required AdConfig config, required VoidCallback onDismiss});
}
// AppId/SlotId 必须从 MMKV 读(/app/start 下发),禁止硬编码
```
**7. `im_adapter.dart`**(OpenIM,最复杂,见阶段七)
**8. `payment_adapter.dart`**(微信/支付宝/IAP,见阶段八)
**9. `adapter_registry.dart`** — 统一初始化入口:
```dart
class AdapterRegistry {
static late HapticAdapter haptic;
static late WebViewAdapter webView;
static late SecurityAdapter security;
static late AnalyticsAdapter analytics;
static late PushAdapter push;
static late AdsAdapter ads;
static late ImAdapter im;
static late PaymentAdapter payment;
/// 在 main() 中调用,顺序固定
static Future<void> initialize() async {
haptic = RealHapticAdapter();
webView = RealWebViewAdapter();
security = RealSecurityAdapter();
await security.initialize(); // freerasp 最先初始化(内部有 5s 超时兜底)
// analytics / ads 不在这里 init,等用户同意隐私协议后
analytics = RealAnalyticsAdapter();
push = RealPushAdapter();
ads = RealAdsAdapter();
im = RealImAdapter();
payment = RealPaymentAdapter();
}
}
```
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现 lib/core/adapters/security_adapter.dart,包含接口抽象类和 RealSecurityAdapter 实现。
要求:
1. 抽象接口:isHighRisk / isRooted / isJailbroken / isEmulator 四个 getter,initialize() 方法
2. 实现基于 freerasp ^7.5.0
3. start() 必须加 .timeout(Duration(seconds: 5)) + try-catch 兜底,防止启动阻塞
4. 检测结果写入 AppStorage(StorageKeys.deviceRiskFlag),供 AuthInterceptor 读取注入 X-Device-Risk-Flag Header
5. 禁止本地弹窗或任何 UI 提示,纯静默处理
6. app 生命周期内持续监听(freerasp 支持持续检测)
```
---
## 阶段五:启动流程 + 隐私合规(Day 5)
这是合规红线,顺序一旦错了可能导致下架。
### Step 5.1 隐私合规墙(必须最先)
**`lib/core/privacy/privacy_service.dart`**:
```dart
class PrivacyService {
static bool get hasAgreed =>
AppStorage.getBool(StorageKeys.privacyAgreed) ?? false;
static void markAgreed() {
AppStorage.setBool(StorageKeys.privacyAgreed, true);
// 触发采集 SDK 初始化(用户同意后才可调用)
AdapterRegistry.analytics.init();
}
}
```
**`lib/features/privacy/privacy_page.dart`**:
- 首次启动检测 MMKV 是否有同意记录
- 没有则全屏展示隐私协议弹窗
- "隐私政策"和"用户协议"链接通过 WebViewAdapter 打开
- 用户点「同意并继续」后调用 `PrivacyService.markAgreed()`,继续启动流程
- 用户点「不同意」直接退出 App(`SystemNavigator.pop()`)
### Step 5.2 `/app/start` 接口
**`lib/features/splash/api/app_start_api.dart`** + **`app_start_real_api.dart`**
⚠️ Riverpod 3.x:provider 函数参数改为通用 `Ref`:
```dart
@riverpod
AppStartApi appStartApi(Ref ref) { // ← 通用 Ref,不是 AppStartApiRef
return RealAppStartApi(ref.watch(mainDioProvider));
}
```
返回数据包含:
- `app_config`:系统业务配置(存 MMKV)
- `feature_flags`:Feature Flag
- `min_version` + `force_update`:强制更新检测
### Step 5.3 AdWaterfallManager
**`lib/core/adapters/ads_adapter.dart`** 内实现瀑布流逻辑:
1. 读 MMKV 缓存的 `ad_config`,按 `priority` 排序
2. 依次尝试 `self` → `csj` → `gdt`
3. 某源超时(>3s)/ 失败 → 自动降级到下一源
4. 全部失败 → 调用 `onDismiss`,跳过广告进入 App
5. 首次冷启动无缓存 → 直接跳过广告,后台异步更新配置
### Step 5.4 Splash 状态机
**`lib/features/splash/splash_page.dart`**:
```
启动状态机(禁止在 main() 堆叠逻辑):
① WidgetsFlutterBinding.ensureInitialized()
② MMKV.initialize()
③ AppStorage.init()
④ AdapterRegistry.initialize()(其中 freerasp 最先初始化,含 5s 超时保护)
⑤ 检查隐私协议 → 未同意则展示隐私弹窗
⑥ 调用 /app/start 接口(有缓存时直接用缓存,并发请求更新)
⑦ 解析 sdk_config → 初始化广告 SDK AppId(还不展示广告)
⑧ 检查 force_update → 如需强制更新则跳转更新页
⑨ 展示启动广告(AdWaterfallManager,iOS 14+ 需先请求 ATT)
⑩ 检查登录态 → 未登录跳 login / 已登录跳 home
```
### Step 5.5 完善路由守卫(⚠️ go_router 17.x 新签名)
在 `app_router.dart` 的 `redirect` 中(必须 async):
```dart
@riverpod
GoRouter router(Ref ref) { // ← Riverpod 3.x: 通用 Ref
return GoRouter(
initialLocation: AppRoutes.splash,
observers: [TalkerRouteObserver(talker)],
redirect: (BuildContext context, GoRouterState state) async {
// ← go_router 17.x: 必须 async
final location = state.matchedLocation;
// 放行这两个路由,避免 redirect loop
if (location == AppRoutes.splash || location == AppRoutes.privacy) {
return null;
}
final agreed = AppStorage.getBool(StorageKeys.privacyAgreed) ?? false;
if (!agreed) return AppRoutes.privacy;
final token = AppStorage.getString(StorageKeys.accessToken);
if (token == null) return AppRoutes.login;
return null;
},
routes: [
GoRoute(path: AppRoutes.splash, builder: (_, __) => const SplashPage()),
GoRoute(path: AppRoutes.privacy, builder: (_, __) => const PrivacyPage()),
GoRoute(path: AppRoutes.login, builder: (_, __) => const LoginPage()),
GoRoute(path: AppRoutes.home, builder: (_, __) => const HomePage()),
GoRoute(path: AppRoutes.banned, builder: (_, state) =>
BannedPage(banInfo: state.extra)),
],
);
}
```
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现 Splash 启动状态机(lib/features/splash/splash_page.dart)。
要求:
1. 状态机枚举:loading / checkingPrivacy / loadingConfig / showingAd / checkingAuth / done
2. 严格按以下顺序执行:MMKV初始化 → 隐私检查 → /app/start接口 → 广告展示 → 登录态检查
3. 首次冷启动无 ad_config 缓存时跳过广告
4. /app/start 请求失败时使用 MMKV 缓存降级,无缓存则直接进入 App
5. 所有步骤用 Riverpod AsyncValue 管理,失败有兜底逻辑不能卡死
6. HookConsumerWidget,使用 flutter_animate 添加品牌 Logo 淡入动画
7. 注意:provider 函数参数使用通用 Ref(Riverpod 3.x),redirect 使用 async 签名(go_router 17.x)
```
---
## 阶段六:推送原生层(Day 6)
### Step 6.1 阿里云推送(纯原生 SDK)
MethodChannel 名称:`com.xiaopaix.app/push`
**iOS AppDelegate.swift**(原生初始化):
```swift
// didFinishLaunchingWithOptions 中
// ⚠️ 正确参数名:withAppkey / appSecret(非 withAccessKey / secretKey)
CloudPushSDK.start(withAppkey: "YOUR_KEY", appSecret: "YOUR_SECRET") { result in
// result 非 optional,直接访问 result.success
}
CloudPushSDK.setPushTokenUploadHandler { token in /* 上传 token 到业务后端 */ }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { ... }
```
**Android `app/build.gradle`**:
```groovy
android {
defaultConfig {
manifestPlaceholders = [
PUSH_APPKEY : "YOUR_KEY",
PUSH_APPSECRET: "YOUR_SECRET"
]
}
}
```
**Dart 侧获取推送 DeviceId**(上传后端关联用户,实现定向推送):
```dart
final deviceId = await _channel.invokeMethod('getDeviceId');
```
**Xcode → Signing & Capabilities** 必须勾选:
- Background fetch(后台唤醒拉取 IM 消息)
- Remote notifications(接收静默推送)
### Step 6.2 阿里云反馈(AlicloudFeedback)
与推送共享同一 AppKey/Secret,iOS Podfile 中已包含(见 Podfile 模板)。同样存在 `PhaseScriptExecution` 失败问题,用同样的 `post_install` 脚本 neutralize 处理。
### Step 6.3 穿山甲广告(Pangle/CSJ)+ 优量汇(GDT)
### Step 6.2 [DEPRECATED] 广告原生层移除
已按照最新要求,移除穿山甲(Pangle/CSJ)和优量汇(GDT)的所有业务逻辑及配置。
### Step 6.4 其他 SDK 原生实现状态
> 以下 SDK 均已在原生侧完整实现,无需额外操作。
| SDK | iOS 实现位置 | Android 实现位置 | 状态 |
|-----|------------|-----------------|------|
<!-- | 友盟统计 (Umeng) | `AppDelegate.didFinishLaunching`:`UMConfigure.initWithAppkey` | `MainActivity.configureFlutterEngine`:`UMConfigure.init` | ✅ 完成 | -->
| 阿里云推送 | `AppDelegate.didFinishLaunching`:`CloudPushSDK.start` | `MainApplication.onCreate`:`PushServiceFactory.init` | ✅ 完成 |
| 穿山甲/优量汇广告 | (已移除) | (已移除) | — |
### Step 6.4 [DEPRECATED] 广告瀑布流移除
已由后端 `/app/start`接口关闭广告开关并清空策略配置。
---
## 阶段七:认证体系(Day 7)
### Step 7.1 设备信息服务
**`lib/core/utils/device_info_service.dart`**:
```dart
// 整合 device_info_plus ^12.3.0 + flutter_udid ^4.1.2
// 提供:deviceId / osName / appVersion
// 结果缓存 MMKV,供 AuthInterceptor 读取
```
在 `AdapterRegistry.initialize()` 中添加设备信息初始化。
### Step 7.2 Auth 功能完整实现
**文件清单**:
- `lib/features/auth/api/auth_api.dart`
- `lib/features/auth/api/auth_real_api.dart`
- `lib/features/auth/auth_repository.dart`
- `lib/features/auth/auth_service.dart`
- `lib/features/auth/auth_controller.dart`
- `lib/features/auth/login_page.dart`
**AuthRepository 核心方法**:
```dart
Future<void> login(String phone, String code);
Future<void> register(String phone, String code, ...);
Future<void> logout();
Future<void> refreshToken();
static Future<void> forceLogout(); // 供 Dio 拦截器调用
```
**Token 存储**:
- Access Token(2h)→ `AppStorage.setString(StorageKeys.accessToken, token)`
- Refresh Token(30d)→ `AppStorage.setString(StorageKeys.refreshToken, token)`
- 登出时调用 `/auth/logout`,清除所有本地 Token
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现认证模块(features/auth/),包含完整五层。
接口:
- POST /auth/login 请求:{phone, code} 响应data:{access_token, refresh_token, user_id}
- POST /auth/logout 无参数
- POST /auth/token/refresh 请求:{refresh_token} 响应data:{access_token, refresh_token}
要求:
1. AuthRepository 提供 forceLogout() 静态方法,可被 Dio 拦截器调用
2. Token 存入 AppStorage 加密 MMKV,key 使用 StorageKeys 常量
3. AuthController 用 @riverpod class,AsyncValue 三态全处理
4. 登录成功后 NavigationService.go(AppRoutes.main)
5. forceLogout 清除所有 Token 后 NavigationService.go(AppRoutes.login)
6. 所有 @riverpod 函数参数使用通用 Ref(Riverpod 3.x 要求)
```
---
## 阶段八:支付体系(Day 8)
### Step 8.1 payment_adapter.dart
三路支付统一接口:
```dart
abstract class PaymentAdapter {
Future<PaymentResult> payWithWechat(WechatPayParams params);
Future<PaymentResult> payWithAlipay(String orderInfo);
Future<PurchaseResult> purchaseIap(String productId);
Future<List<ProductDetails>> getIapProducts(List<String> productIds);
Future<void> restorePurchases();
}
```
**各路支付关键注意点**:
- **微信支付**(fluwx ^5.7.5):需原生配置 URL Scheme(iOS Info.plist + Android AndroidManifest)
- **支付宝**(tobias ^5.3.4):Android 需在 MainActivity 中配置 Activity 回调
- **IAP 应用内购**(in_app_purchase ^3.2.3):iOS 必须接入,否则 App Store 审核被拒;支付结果不信任客户端,必须后端验票
**重要原则**:支付结果不依赖客户端回调,只负责唤起支付页。结果通过后端轮询 `/order/status` 或服务端推送 + 推送通知确认。
### Step 8.2 微信 fluwx 原生配置
**`pubspec.yaml`** 添加 fluwx 配置节:
```yaml
fluwx:
app_id: "wx_your_wechat_id"
universal_link: "https://www.yourapp.com/app/"
```
**iOS `Info.plist`**:
```xml
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
<string>alipay</string>
<string>alipays</string>
</array>
<!-- URL Types 中添加 Scheme = 微信 AppId -->
```
**iOS `Info.plist` Associated Domains**:添加 `applinks:www.yourapp.com`(Universal Link 必须配置,且服务器 `.well-known/apple-app-site-association` 必须可访问)
**iOS Podfile** 必须加这两行环境变量,否则 pod install 会报 Ruby 错误:
```ruby
ENV['FLUWX_SKIP_SETUP'] = 'true'
ENV['TOBIAS_SKIP_SETUP'] = 'true'
```
**Android**:新建 `android/app/src/main/java/[包名]/wxapi/WXEntryActivity.java`,继承 `WXCallbackActivity`;`AndroidManifest.xml` 注册该 Activity。
### Step 8.3 支付宝 tobias 原生配置
**`pubspec.yaml`** 添加 tobias 配置节(已在全局 pubspec 中配置):
```yaml
tobias:
url_scheme: "com.xiaopai.match"
```
**iOS `Info.plist`** 的 `URL Types` 中添加 Scheme = `com.xiaopai.match`。
支付完成回调通过 `listenAliPayResult()` 监听,**注意在页面 `dispose` 时取消订阅**,否则内存泄漏。
### Step 8.4 IAP 应用内购原生配置
**Xcode → Signing & Capabilities → 添加 `In-App Purchase`**(必须,缺少则 StoreKit 无法使用)。
`purchaseStream` 监听推荐在 `ProviderScope` 级别维护,保持整个应用生命周期活跃。
沙盒测试需在 App Store Connect 创建沙盒账号;本地调试可用 `StoreKitConfiguration` 文件,不依赖 App Store Connect。
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现 lib/core/adapters/payment_adapter.dart,包含 PaymentAdapter 接口和 RealPaymentAdapter 实现。
要求:
1. 三路支付:微信(fluwx ^5.7.5)/ 支付宝(tobias ^5.3.4)/ IAP(in_app_purchase ^3.2.3)
2. 统一返回 PaymentResult(freezed 3.x 模型),包含 success / orderId / error 字段
3. 支付结果不负责轮询订单,只负责唤起并返回客户端结果码
4. IAP purchaseStream 在整个应用生命周期保持活跃
5. 支付宝 listenAliPayResult 订阅需在调用方 dispose 时取消
```
---
## 阶段九:IM 接入(Day 9)
### Step 9.1 完整实现 im_adapter.dart
基于 `flutter_openim_sdk ^3.8.3`,**关键点**:
```dart
// ⚠️ platformID 必须固定为 5(自定义端),禁止动态检测
const int _platformId = 5;
```
**Token 刷新逻辑**:
```dart
// Token 过期错误码 10004 / 10005
// onUserTokenExpired 回调 → 调用传入的 refreshImToken 闭包 → 重新 login()
```
**网络恢复重连**:
```dart
// 监听 connectivity_plus ^7.0.0
// 网络恢复时检查 !_isLoggedIn → 触发重试登录
```
**对外暴露的 Stream**:
```dart
Stream<Message> get onNewMessage;
Stream<List<ConversationInfo>> get onConversationChanged;
Stream<UserOnlineStatus> get onUserOnlineStatus;
```
---
**与 AI 交流此步的提示词:**
```
【规范】(粘贴 frontend.md)
帮我实现 lib/core/adapters/im_adapter.dart,包含 ImAdapter 接口 and RealImAdapter 实现。
要求:
1. platformID 固定写 5,代码注释说明原因,禁止任何动态检测逻辑
2. Token 过期(10004/10005)触发 onUserTokenExpired 回调,参数是 Future<String> Function() refreshImToken 闭包,刷新后重新调用 login()
3. 监听 connectivity_plus ^7.0.0,网络恢复且 !_isLoggedIn 时自动重试登录
4. 暴露 Stream<Message> onNewMessage 和 Stream<List<ConversationInfo>> onConversationChanged 供业务层订阅
5. login() / logout() 与 AuthController 状态联动(通过传入回调解耦)
6. RealImAdapter 通过 ImAdapter 接口抽象,AdapterRegistry 只持有 ImAdapter 引用
7. 基于 flutter_openim_sdk ^3.8.3
```
---
## 阶段十:业务 Feature 开发范式(Day 10+)
到这里基础设施全部就绪,开始正式写业务。
### 每个 Feature 的标准开发流程
**Step 1:先查 shared.dart**
告诉 AI:"先列出 `lib/shared.dart` 和 `lib/widgets/` 里有哪些可以复用的组件,再开始实现"。
**Step 2:建目录和文件**
```
lib/features/{feature_name}/
├── {feature}_page.dart # UI,HookConsumerWidget
├── {feature}_controller.dart # 状态,@riverpod class
├── {feature}_service.dart # 业务规则
├── {feature}_repository.dart # 数据访问,catch AppApiError
├── api/
│ ├── {feature}_api.dart # 接口抽象
│ └── {feature}_real_api.dart # 真实实现(⚠️ provider 函数参数用通用 Ref)
└── widgets/ # 该 Feature 私有组件
```
**Step 3:建 freezed 数据模型**(freezed 3.x,语法与 2.x 兼容)
```dart
@freezed
class XxxModel with _$XxxModel {
const factory XxxModel({
required String id,
required String name,
@Default(false) bool isLoading,
}) = _XxxModel;
factory XxxModel.fromJson(Map<String, dynamic> json) =>
_$XxxModelFromJson(json);
}
```
跑:`dart run build_runner build --delete-conflicting-outputs`
**Step 4:Repository 层错误处理模板**
```dart
Future<void> doSomething() async {
try {
final res = await _api.doSomething();
// 消费 res.data
} on AppApiError catch (e) {
if (e.canWatchAd) {
AdWaterfallManager.showUnlockAd(config: e.data?['ad_config']);
} else {
AppToast.show(e.message);
}
} on DioException catch (_) {
AppToast.show('网络异常,请检查网络连接');
}
}
```
**Step 5:Controller 三态模板**(Riverpod 3.x,`@riverpod class` 写法不变)
```dart
@riverpod
class XxxController extends _$XxxController {
@override
Future<List<XxxModel>> build() async {
return ref.watch(xxxRepositoryProvider).fetchList();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() =>
ref.read(xxxRepositoryProvider).fetchList());
}
}
```
**Step 6:Page 模板**
```dart
class XxxPage extends HookConsumerWidget {
const XxxPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(xxxControllerProvider);
return Scaffold(
appBar: AppBar(title: 'xxx'.text.make()),
body: state.when(
data: (list) => AppListView(
items: list,
itemBuilder: (item) => XxxCard(item: item),
),
loading: () => const SkeletonWidget(),
error: (err, stack) => AppEmptyState(
type: err is AppApiError && err.isNetworkError
? AppEmptyType.networkError
: AppEmptyType.serverError,
onAction: () => ref.refresh(xxxControllerProvider),
),
),
);
}
}
```
### 标准提问模板(每次业务开发都用这个)
```
【规范】(粘贴 frontend.md 全文)
【UI 规范】(粘贴 frontend_ui.md 全文)
【需求】实现 [feature 名] 的 [具体页面/功能]
【接口】
- POST /xxx/yyy
- 请求:{ field1: type, field2: type }
- 响应 data:{ field1: type, field2: type }
【UI 描述】
[描述页面结构、卡片样式、交互行为]
【特殊逻辑】
- 需要乐观更新(点赞/关注)
- 特殊错误码处理(code=3007 弹广告)
- 某组件四态(语音消息 idle/loading/playing/paused)
请生成完整五层文件(api / repository / service / controller / page),
以及需要的 freezed 数据模型。
注意:所有 @riverpod 函数参数使用通用 Ref(Riverpod 3.x 要求)。
```
---
## 阶段十一:发布准备
### Step 11.1 图标配置
在 `pubspec.yaml` 中配置(已在全量依赖中含 `flutter_launcher_icons` 节):
```yaml
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/images/icon.png" # 1024x1024 png
adaptive_icon_background: "#010005"
adaptive_icon_foreground: "assets/images/icon.png"
web:
generate: false
windows:
generate: false
```
执行:`dart run flutter_launcher_icons`
### Step 11.2 iOS 合规配置
**PrivacyInfo.xcprivacy**(Runner 目录下必须有此文件):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array></array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
```
**Background Modes**(Xcode → Signing & Capabilities):
- 勾选 Background fetch(IM 后台拉消息)
- 勾选 Remote notifications(远程推送)
### Step 11.3 权限声明审查
iOS Podfile 中只声明**实际使用**的权限,未使用的权限会导致 App Store 审核被拒。常见权限 key:
```ruby
# 只保留实际用到的:
# 相机:NSCameraUsageDescription
# 麦克风:NSMicrophoneUsageDescription
# 相册:NSPhotoLibraryUsageDescription
# 推送:已通过 Remote Notifications 申请,不需要 Info.plist key
```
### Step 11.4 发布前必跑检查项
```bash
# 1. 静态分析,消除所有 warning
flutter analyze
# 2. 代码生成确认无遗漏(⚠️ 使用 dart run,flutter pub run 已废弃)
dart run build_runner build --delete-conflicting-outputs
# 3. 国际化生成
flutter gen-l10n
# 4. Release 构建测试(dev 环境先验证)
flutter build apk --dart-define-from-file=env.dev.json
flutter build ipa --dart-define-from-file=env.dev.json
# 5. 正式 Release 构建
flutter build apk --dart-define-from-file=env.prod.json
flutter build ipa --dart-define-from-file=env.prod.json
```
### Step 11.5 阿里云实名认证(aliyun_face_plugin)
**接入方式**:本地 Plugin(`plugins/aliyun_face_plugin/`),pubspec.yaml 使用 path 依赖:
```yaml
aliyun_face_plugin:
path: plugins/aliyun_face_plugin
```
**不提前初始化**,用户进入实名认证页时按需初始化:
```dart
// 完整核验流程(顺序不可乱)
// 1. plugin.getMetaInfos() → 获取设备元信息
// 2. POST /auth/verify/token {meta} → 后端换 verifyToken
// 3. plugin.verify(verifyToken) → 唤起实名认证 SDK
// 4. POST /auth/verify/check → 回调成功后后端核查结果
```
iOS 需要声明 `NSCameraUsageDescription`(人脸识别需要相机权限)。
### Step 11.6 图片与音频处理注意事项
**图片压缩规则**(`flutter_image_compress ^2.4.0`):
```dart
// 文件大小 > 200KB 时压缩,quality=80,输出 JPEG
if (fileSize > 200 * 1024) {
await FlutterImageCompress.compressAndGetFile(
path, targetPath, quality: 80
);
}
// 裁剪返回的临时文件存 getTemporaryDirectory(),上传完成后删除
// 选择器返回 AssetEntity,需调用 .file 转换为 File
```
**音频录制**(`record ^6.2.0`):
```yaml
# Linux 桌面端兼容(项目不用但避免 pub get 报错)
dependency_overrides:
record_linux: ^1.1.0
```
录制返回 `.m4a` 文件,上传前无需压缩。播放使用 `just_audio ^0.10.5`,**`dispose` 时必须 `await player.dispose()`**,否则内存泄漏。
---
## iOS Podfile 通用完整模板
每次新建项目或 pod install 出问题时,以此模板为基准:
```ruby
source 'https://github.com/CocoaPods/Specs.git'
source 'https://github.com/aliyun/aliyun-specs.git' # 阿里云 SDK 必须,且必须在官方 source 之后
platform :ios, '13.0'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
ENV['FLUWX_SKIP_SETUP'] = 'true' # 微信 SDK,跳过 Ruby setup 脚本
ENV['TOBIAS_SKIP_SETUP'] = 'true' # 支付宝 SDK
target 'Runner' do
use_frameworks!
# 广告 SDK
pod 'Ads-CN' # 穿山甲国内版
pod 'GDTMobSDK' # 腾讯广告联盟
# 阿里云
pod 'AlicloudPush', '~> 3'
pod 'AlicloudFeedback', '~> 3.4.2'
# 友盟(必须 modular_headers,否则头文件找不到)
pod 'UMCommon', :modular_headers => true
pod 'UMDevice', :modular_headers => true
# 微信(必须 modular_headers)
pod 'WechatOpenSDK-XCFramework', :modular_headers => true
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.build_configurations.each do |config|
config.build_settings.delete 'VALID_ARCHS'
config.build_settings['ARCHS'] = 'arm64 x86_64'
end
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
# ① 中和阿里云 SDK 的 Strip 脚本(否则 CI 打包失败)
target.shell_script_build_phases.each do |phase|
if phase.name.include?("Strip") || phase.name.include?("archs")
phase.shell_script = "echo 'neutralized'"
end
end
target.build_configurations.each do |config|
config.build_settings.delete 'VALID_ARCHS'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
config.build_settings['ENABLE_BITCODE'] = 'NO' # 阿里云不支持 Bitcode
config.build_settings['GCC_WARN_INHIBIT_ALL_WARNINGS'] = 'YES'
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
# ② 友盟 + 微信头文件搜索路径(只对这两个 target 注入)
if ['fluwx'].include?(target.name)
config.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited) '
# config.build_settings['HEADER_SEARCH_PATHS'] << '"${PODS_ROOT}/Headers/Public/UMCommon" '
# config.build_settings['HEADER_SEARCH_PATHS'] << '"${PODS_ROOT}/Headers/Public/UMDevice" '
config.build_settings['HEADER_SEARCH_PATHS'] << '"${PODS_ROOT}/Headers/Public/WechatOpenSDK-XCFramework" '
end
# ③ permission_handler 权限宏(只声明实际使用的,多余的会导致 App Store 审核被拒)
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_NOTIFICATIONS=1',
'PERMISSION_MICROPHONE=1',
'PERMISSION_CAMERA=1',
'PERMISSION_PHOTOS=1',
]
end
end
end
```
---
## SDK 接入顺序总表
| 顺序 | SDK / 能力 | 原因 | 阶段 |
|------|-----------|------|------|
| 1 | MMKV | 所有 SDK 都需要存配置,必须最先 | 阶段一 |
| 2 | envied 1.x | 密钥混淆,各 SDK 初始化需要 | 阶段一 |
| 3 | Dio + 拦截器 | 网络是所有业务的基础 | 阶段三 |
| 4 | freerasp 7.x | 影响所有请求的 X-Device-Risk-Flag | 阶段四 |
| 5 | device_info_plus 12.x + flutter_udid 4.x | 请求 Header 需要设备 ID | 阶段七 |
| 6 | flex_color_scheme + 主题体系 | UI 组件依赖主题,必须先就绪 | 阶段二 |
| 7 | go_router 17.x | 隐私合规弹窗和路由守卫依赖它 | 阶段二 |
| 8 | flutter_inappwebview 6.x | 隐私政策必须用 WebView 展示 | 阶段四 |
| 9 | 隐私合规弹窗 | 合规红线,后续采集 SDK 必须在它之后 | 阶段五 |
<!-- | 10 | umeng_common_sdk | 用户同意隐私协议后才能初始化 | 阶段四/五 | -->
| 11 | flutter_openim_sdk 3.8.x | IM,依赖 auth Token,登录后才 login | 阶段九 |
| 12 | fluwx 5.x + tobias 5.x + IAP | 支付,需要原生配置 | 阶段八 |
| 13 | 阿里云推送(原生) | 纯原生 SDK,需要原生工程配置 | 阶段六 |
| 14 | 穿山甲/腾讯广告(原生) | AppId 由 /app/start 下发,最后初始化 | 阶段六 |
| 15 | aliyun_face_plugin | 按需接入,不提前初始化 | 阶段十一 |
---
## 常见编译错误速查
| 错误 | 原因 | 解决方案 |
|------|------|----------|
| `Undefined class 'XxxRef'`(如 `MainDioRef`、`RouterRef`、`AppStartApiRef`) | Riverpod 3.x 废除了独立 Ref 类型 | 将所有 `XxxRef` 替换为通用 `Ref` |
| `redirect must return FutureOr<String?>` | go_router 17.x redirect 签名变化 | 给 `redirect` 函数加 `async` 关键字 |
| `sdk constraint mismatch` | pubspec.yaml sdk 约束太低 | 改为 `sdk: '>=3.8.0 <4.0.0'` |
| `TalkerRiverpodObserver positional argument` | talker 5.x 参数改为命名参数 | 改为 `TalkerRiverpodObserver(talker: talker)` |
| `第二次启动 App 黑屏(runApp 未执行)` | `Talsec.instance.start()` 在某些机型下永久阻塞 | `security_adapter.dart` 中对 `Talsec.start()` 加 `.timeout(Duration(seconds: 5))`,并用 try-catch 兜底 |
| `'UMDevice/UMDevice.h' file not found` | UMDevice 3.5+ 为 XCFramework,不暴露 .h 头文件;Swift 代码也未直接调用 UMDevice API | 删除桥接头文件中 `#import <UMDevice/UMDevice.h>` 这一行 |
| `Incorrect argument labels (withAccessKey:secretKey:)` | AlicloudPush 3.x API 改名 | 改为 `CloudPushSDK.start(withAppkey:appSecret:)` |
| `Cannot use optional chaining on CloudPushCallbackResult` | AlicloudPush 3.x 回调 result 非 optional | 去掉 `if let result = result`,直接用 `result.success` |
| `Missing argument for parameter 'withCallback' in registerDevice` | AlicloudPush 3.x registerDevice 需要 callback | 改为 `CloudPushSDK.registerDevice(token, withCallback: { _ in })` |
| `Type 'MobClick' has no member 'setPageCollectionMode'` | UMCommon 7.x 将该方法改名 | 改为 `MobClick.setAutoPageEnabled(false)` |
| `'UMCommon/UMConfigure.h' file not found` | 友盟头文件路径未注入 | Podfile 加 `:modular_headers => true` + `post_install` 注入 `HEADER_SEARCH_PATHS` |
| `Command PhaseScriptExecution failed (AlicloudUtils)` | 阿里云 Strip 脚本失败 | `post_install` 中将含 `Strip`/`archs` 的 shell script 替换为 `echo 'neutralized'` |
| `OpenIM Login Error 10004/10005` | IM Token 过期 | 刷新 imToken,重新调用 `login()` |
| `OSS 签名 URL 请求 403` | 用了带 Auth 头的 Dio 实例 | 使用纯净 `Dio()` 不带 Authorization 头 |
| `GoRouter redirect loop` | 路由守卫对 splash/privacy 路由放行条件错误 | 检查 `redirect` 中对 splash 和 privacy 路由的放行逻辑 |
| `MMKV crash on first launch` | `initialize()` 未最先调用 | 确保在任何 MMKV 读写前 `await MMKV.initialize()` |
| `flutter_hooks reload loop` | `useMemoized` 忘记传 keys | 确保 `useMemoized` / `useCallback` 正确传入依赖项 |
| `OpenIM platformID 导致消息不可见` | platformID 被动态检测 | 固定写 `platformID: 5` |
| `envied 密钥被逆向` | 使用了 `dart-define` 未加 obfuscate | 改用 `@Envied(obfuscate: true)` |
| `Lottie 动画不显示` | asset 未在 pubspec 注册 | 确认 `assets/lottie/` 已在 `pubspec.yaml` 的 `flutter.assets` 中声明 |
| `riverpod 版本冲突` | 三包版本不一致 | `riverpod_annotation ^4.0.2` + `hooks_riverpod ^3.3.1` + `riverpod_generator ^4.0.3` 一起升 |
| `build_runner 生成文件冲突` | 旧生成文件残留 | 运行 `dart run build_runner build --delete-conflicting-outputs` |
| `flutter pub run build_runner Deprecated` | 旧命令已废弃 | 改用 `dart run build_runner` |
| `pod install Ruby 报错 (fluwx/tobias)` | fluwx/tobias 自带 Ruby setup 脚本在非标准环境失败 | Podfile 顶部加 `ENV['FLUWX_SKIP_SETUP']='true'` 和 `ENV['TOBIAS_SKIP_SETUP']='true'` |
| `Universal Link 无效,微信无法回调` | 服务器未配置 AASA 文件或 Associated Domains 未添加 | 检查 `.well-known/apple-app-site-association` 是否可访问,Xcode 是否添加 `applinks:` |
| `支付宝 listenAliPayResult 内存泄漏` | dispose 时未取消监听 | 页面 dispose 时调用取消订阅 |
| `IAP 无法购买(StoreKit 报错)` | Xcode 未开启 In-App Purchase Capability | Xcode → Signing & Capabilities → 添加 In-App Purchase |
---
*文档版本:v2.1 · 基于同伴 App 前端开发规范 v5 · Flutter 3.41.x / Dart 3.11.x / Riverpod 3.x / freezed 3.x / go_router 17.x*