项目背景
某内容平台的移动端原本是 iOS、Android 各一套原生代码,两个团队 6 个人维护,每次新功能都要双端各排一次期,发版节奏跟不上。更麻烦的是双端交互细节经常不一致,用户反馈「为什么安卓没有 iOS 的那个动画」。运营的诉求是统一体验、提效发版。
本次目标是用 Flutter 重构资讯类 App,一套 Dart 代码同时出 iOS/Android 两端,团队缩到 3 人,双端功能与视觉强一致,首屏渲染低于 1.2s,包体积控制在 18MB 内。技术栈选 Flutter 3 + Riverpod(状态管理)+ Dio(网络)+ go_router(路由)。
跨平台不是「写一遍跑两端」那么轻松,真正的工程量在状态管理、原生通信与包体积优化这三处,UI 反而最简单。
架构设计
采用分层架构:UI 层只渲染不持有业务;状态层用 Riverpod Provider 承载;数据层封装 Dio 与本地缓存;平台层用 MethodChannel 处理原生能力。各层单向依赖,方便单测与替换。
lib/
├── main.dart # 入口,挂 ProviderScope 与路由
├── ui/ # 页面与组件(不写业务逻辑)
│ ├── pages/
│ └── widgets/
├── providers/ # Riverpod 状态(article、user、settings)
├── data/ # 网络仓库 + 本地缓存
│ ├── remote/ # Dio 实现
│ └── local/ # SharedPreferences / Hive
├── platform/ # MethodChannel 原生通信
└── router.dart # go_router 路由表
关键设计:所有网络请求经 Repository 封装,UI 只读 Provider 不直接调网络;路由用 go_router 声明式管理,深链接直接对应路由路径;原生能力(推送、生物识别)收敛到 platform/ 一处,便于双端各自实现。
核心实现
1)状态管理:用 Riverpod 的 AsyncNotifier 承载文章列表的「加载中/成功/失败」三态,UI 用 ref.watch 订阅,ref.read 触发刷新,避免 setState 满天飞。
// providers/article_provider.dart —— 文章列表状态
class ArticleListNotifier extends AsyncNotifier<List<Article>> {
@override
Future<List<Article>> build() => ref.read(articleRepo).fetchList();
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(articleRepo).fetchList(),
);
}
}
final articleListProvider =
AsyncNotifierProvider<ArticleListNotifier, List<Article>>(
ArticleListNotifier.new);
// UI 使用
final list = ref.watch(articleListProvider);
return list.when(
loading: () => const LoadingShimmer(),
error: (e, _) => ErrorRetry(onRetry: () => ref.refresh(articleListProvider)),
data: (items) => ArticleListView(items: items),
);
2)原生通信:生物识别登录用 MethodChannel 调原生,Flutter 侧定义统一接口,iOS/Android 各自实现,失败降级到密码登录。
// platform/biometric.dart —— 原生通信封装
const _channel = MethodChannel('app.biometric');
Future<bool> authenticate(String reason) async {
try {
final ok = await _channel.invokeMethod<bool>('authenticate', {'reason': reason});
return ok ?? false;
} on PlatformException {
return false; // 原生不可用时降级
}
}
// Android 侧(Kotlin)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "app.biometric")
.setMethodCallHandler { call, result ->
when (call.method) {
"authenticate" -> biometricPrompt(result)
else -> result.notImplemented()
}
}
3)包体积优化:Flutter 默认产物偏大,我们通过「移除未用字体子集 + 按需 import 图标 + --split-per-abi 拆 ABI + 资源托管远端按需下载」四步把包从 32MB 压到 17MB。
- 首屏渲染:2.4s → 1.1s(预编译 + 骨架屏)
- 包体积:32MB → 17MB(--split-per-abi 后单架构)
- 发版人力:6 人 → 3 人
- 新功能双端交付周期:2 周 → 5 天
技术难点
难点一:长列表性能。资讯 Feed 滑动时掉帧明显。用 DevTools 的 Performance 面板定位到是 ListView.builder 里每项都重建了重型组件。改用 const 构造、抽取 itemExtent 固定高度、对图片用 cached_network_image 做内存缓存后,滑动稳定 60fps。
// 长列表优化:固定 itemExtent + const 组件
ListView.builder(
itemExtent: 96, // 固定高度,跳过测量
itemCount: items.length,
itemBuilder: (_, i) => ArticleTile(item: items[i]), // ArticleTile 用 const 构造
)
// ArticleTile 内部图片走缓存
CachedNetworkImage(
imageUrl: item.cover,
memCacheWidth: 200, // 限制解码尺寸
placeholder: (_, __) => const Skeleton(),
)
难点二:WebView 与原生混排。部分活动页是 H5,要用 webview_flutter 嵌入并做 JS Bridge 通信。难点是滚动嵌套时手势冲突,最终通过 NeverScrollableScrollPhysics 让 WebView 高度自适应内容、由外层 ListView 统一滚动解决。
难点三:iOS 与 Android 字体渲染差异。同一字号在两端视觉大小不一致。我们定义了统一的 ThemeData,对 TextTheme 按 Platform 微调 letterSpacing 与 height,保证双端视觉一致。
踩坑复盘
坑 1:Riverpod 误用 ref.watch 在 build 之外。在事件回调里写了 ref.watch 导致状态不更新且报错。规范是「build 内 watch、回调内 read」,团队加了一条 lint 规则后杜绝。
坑 2:Dio 拦截器里抛异常被吞。统一错误处理拦截器里 catch 后没 rethrow,导致上层永远拿到成功。改成 rethrow 后错误能正确传到 UI 层。教训:拦截器处理完异常要决定「吞掉」还是「上抛」,不能模棱两可。
坑 3:上架被拒——iOS 因「使用未声明权限」被打回。是某个三方插件静默引入了定位权限。用 pod install 后检查 Info.plist 权限声明、移除未用插件后过审。教训:上线前必须 review 最终权限列表,三方插件的权限要逐一确认。
Flutter 工程化的胜负手:状态管理要规范、长列表要 const、包体积要拆 ABI——这三件做扎实,跨平台才真正省人力。
项目总结
App 上线后双端体验完全一致,团队从 6 人缩到 3 人,新功能双端交付周期从 2 周缩到 5 天,包体积比原双端各自方案还小 30%。整个项目最大的认知更新是:跨平台的价值不在「少写一份 UI」,而在「统一状态、统一交互、统一发版节奏」带来的工程效率提升。
- 首屏渲染:2.4s → 1.1s
- 包体积:32MB → 17MB
- 维护人力:6 人 → 3 人
- 功能交付周期:2 周 → 5 天