我们团队用 Flutter 交付了一款物业管理 App,iOS 上了 App Store,Android 覆盖了 OPPO / vivo / 小米 / 荣耀 四家国内应用市场。回头看,Flutter 把 UI 和业务逻辑的复用率做到了 90% 以上,但真正消耗工时的从来不是"写页面",而是双端差异的收口 和上架合规。
这篇文章不讲"Flutter 是什么",只讲从
flutter create到双端上架这条路上,具体怎么做、哪里会踩坑。
一、先把边界划清楚:工程分层决定后期维护成本
跨平台项目最容易失控的地方,是 if (Platform.isIOS) 散落在每一个业务页面里。改成下面这样分层,平台差异被强制收口到几个固定位置:
bash
lib/
├── api/ # 接口定义(按模块拆:login_api / repair_api / patrol_api ...)
├── models/ # 数据模型 + fromJson/toJson
├── pages/ # 页面
├── widgets/ # 通用组件(选择器、日历弹窗、卡片...)
├── utils/ # 工具(网络、日志、水印、存储)
├── services/ # 长生命周期服务(推送、WebSocket、定位、蓝牙)
├── stores/ # 状态管理(我们用的 Provider)
└── main.dart
三条实操原则:
- 业务层不出现 Platform 判断。 需要差异的地方,抽到
services/或utils/里做成统一接口,业务层只调接口。 - 老安卓机是一等公民。 国内存量设备里低端机占比不低,我们的目标机里就有老安卓。所有"吃资源"的操作(大图解码、视频截帧、批量压缩)都必须有降级路径,宁可功能弱一点,也不能卡死或崩。
- Web 只当预览用。
flutter run -d chrome很方便,但别把它当交付目标。蓝牙、iBeacon、写系统相册、视频截帧这类能力在 Web 上要么没有、要么需要额外规避(我们就遇到get_video_thumbnail0.9.0+ 在 Web 平台因setRequestHeader外部扩展类型 interop 编译失败的问题,最后靠本地 patch +dependency_overrides解决)。
二、网络层:一次封装,双端通用
后端统一返回 {code, msg, data},在 Dio 拦截器里一次性处理,业务层拿到的就是干净的 data:
dart
class ApiClient {
static final Dio _dio = Dio(BaseOptions(
baseUrl: 'https://your.domain.com/app',
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
));
static void init() {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
// 统一注入 token、设备信息、版本号
options.headers['Authorization'] = 'Bearer ${TokenStore.token}';
handler.next(options);
},
onResponse: (response, handler) {
final body = response.data as Map<String, dynamic>;
final code = body['code'];
if (code == 200) {
// 关键:解包,让业务层只关心 data
response.data = body['data'];
handler.next(response);
} else {
// 401 登录失效 → 统一跳登录;其他走业务错误回调
handler.reject(DioException(requestOptions: response.requestOptions));
}
},
onError: (e, handler) => handler.next(e),
));
}
}
两个细节值得抄:
- 超时必须显式设置。 老安卓机 + 弱网(地下室、电梯间是物业 App 的高频使用场景)下,不设超时会让请求永久挂起,页面一直转圈。
- 错误要分级。 网络错误、业务错误、登录失效三类分开处理,不要全在 UI 层 toast。
三、原生能力:这才是真正的工作量
跨平台框架省掉的是 UI 代码,省不掉原生能力对接。下面几个是这类 App 绕不开的。
3.1 权限声明:双端都要配,漏一个就崩
| 能力 | iOS Info.plist |
Android AndroidManifest.xml |
|---|---|---|
| 相机 | NSCameraUsageDescription |
android.permission.CAMERA |
| 麦克风 | NSMicrophoneUsageDescription |
android.permission.RECORD_AUDIO |
| 相册读取 | NSPhotoLibraryUsageDescription |
READ_MEDIA_IMAGES(Android 13+)/ READ_EXTERNAL_STORAGE(旧版本) |
| 相册写入 | NSPhotoLibraryAddUsageDescription |
Android 10+ 用 MediaStore,无需权限 |
| 定位 | NSLocationWhenInUseUsageDescription |
ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION |
| 蓝牙 | NSBluetoothAlwaysUsageDescription |
BLUETOOTH_SCAN / BLUETOOTH_CONNECT(Android 12+) |
运行时用 permission_handler 统一申请,并且给"永久拒绝"留出口------否则用户点错一次,功能就永远用不了:
dart
Future<bool> ensureCameraPermission(BuildContext context) async {
final status = await Permission.camera.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
// 必须给「去设置」的入口,否则用户卡死
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: const Text('相机权限被禁用,请在系统设置中开启'),
action: SnackBarAction(label: '去设置', onPressed: () => openAppSettings()),
));
}
return false;
}
3.2 相机:老安卓机的清晰度与稳定性
我们做水印相机时踩了不少坑,最后沉淀出三个关键处理:
① 分辨率分级回退。 veryHigh 在老机型上就是它的原生小图(8~13MP),反而最安全最清楚;新机上 veryHigh 过大导致初始化失败,再依次降级:
dart
const presets = [
ResolutionPreset.veryHigh,
ResolutionPreset.high,
ResolutionPreset.medium,
];
for (final preset in presets) {
try {
_controller = CameraController(camera, preset,
enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg);
await _controller!.initialize();
break; // 成功就停
} catch (e) {
_controller?.dispose(); // 失败释放,换下一档
_controller = null;
}
}
② 点按对焦 + 锁曝光。 拍电表、设备铭牌这类场景,暗背景下红字容易被整体压暗糊掉。点哪对焦哪、并把曝光锚定在点击处,清晰度直接上一个台阶:
dart
controller.setFocusPoint(Offset(sensorX, tapY));
controller.setFocusMode(FocusMode.locked);
controller.setExposurePoint(Offset(sensorX, tapY));
③ 所有相机调用包 try/catch + 超时。 老机型上 takePicture() 在切后台时可能既不返回也不抛错,会让快门永久禁用------必须加超时兜底:
dart
file = await _controller!.takePicture().timeout(const Duration(seconds: 8));
还有个隐蔽的坑:切后台/打开相册时相机被系统回收,返回后访问已 dispose 的 controller 会抛 CameraException(Disposed) 。正确做法是在 didChangeAppLifecycleState 里释放并置空引用,resumed 时重新初始化。
3.3 推送:iOS 和 Android 是两套完全不同的链路
这块是我们花时间最多的地方,最终架构是:
- iOS:APNs 直连(后端直连 APNs,客户端只需申请权限 + 上报 DeviceToken)
- Android:前台 WebSocket 实时 + 极光 JPush 后台兜底 + OPPO/vivo/荣耀/小米/华为厂商通道补位
Android 这样做的原因很现实:国内没有 Google 服务,进程被杀后长连接必断,只能靠厂商系统级通道保活。代价是同一条消息可能被多个通道重复触发,所以必须做去重(我们按 messageId 在客户端做幂等,并加了 1s 冷却防消息轰炸)。
四、性能:老安卓机不卡死是底线
几条我们验证有效的规则:
- 能加
const就加。 全量flutter analyze里prefer_const_constructors是我们重点清理的一类------重建是老机型卡顿的主因。 - 避免整页
setState。 局部刷新的地方拆成独立 Widget 或用ValueNotifier。 - 图片必须压缩。 选图时
imageQuality: 90,批量处理前先按目标尺寸采样,别直接解码几十张原图。 - 耗时操作放
compute()。 水印合成、批量缩放这类 CPU 密集任务走独立 isolate,避免掉帧。 - 列表用
ListView.builder,禁止一次性Column(children: [...])渲染长列表。
五、打包与上架:把合规前置到开发阶段
5.1 Android
bash
# 正式包
flutter build apk --release --split-per-abi
flutter build appbundle --release
几个必查项:
- 签名 :
key.properties别提交进仓库,build.gradle里读取。 - 明文流量 :
AndroidManifest.xml里android:usesCleartextTraffic="false",强制 https。 - 权限瘦身 :用不到的权限全删(我们删掉了未使用的
READ_PHONE_STATE,部分市场会因此拒审)。 - targetSdk:跟进各市场的最新要求,别卡在旧版本。
国内上架的两个前置条件,很多人会漏:
- 软件著作权(下证周期长,建议开发中期就启动)
- APP 备案(工信部要求,需与后端域名的 ICP 备案主体一致)
5.2 iOS
流程大家都熟(证书 → 描述文件 → Archive → Transporter → App Store Connect),重点说几个必踩的坑:
① 隐私描述键必须齐全。 缺一个就是 ITMS-90683 被拒。相机、麦克风、相册读写、定位、蓝牙,用到哪个配哪个。
② Xcode 15 的链接器问题。 部分 Flutter 版本在 Xcode 15 下链接 App.framework 会失败(ITMS-409 上传错误)。解决方案是改用 classic linker:
bash
# 用 -ld_classic 重新链接
xcrun clang -Wl,-ld_classic ...
我们最后的办法是加一个 ld-classic-wrapper.sh 脚本,在 Build Phases 里接管链接步骤。
③ ATS:不配例外就全站 https。 我们没配 NSAppTransportSecurity 例外,所以后端返回的媒体 URL 必须全是 https。如果后端给你 http 域名,让后端改,别在客户端加例外。
④ 隐私清单(Privacy Manifest)。 用到了 Required Reason API(如文件时间戳、磁盘空间、UserDefaults 等)需要在 PrivacyInfo.xcprivacy 里声明用途,否则审核会被卡。
⑤ 后台模式按需声明。 我们只声明了 remote-notification 和 bluetooth-central,多声明会被问为什么要。
六、踩坑清单(速查表)
| 现象 | 根因 | 解决 |
|---|---|---|
| 老安卓机拍照糊 | 自动曝光以整体画面为准,暗背景下主体被压暗 | 点按对焦 + 锁曝光点 |
| 切后台回来相机崩溃 | 访问了已 dispose 的 controller | 生命周期里释放 + 置空,resumed 重建 |
| 快门点了没反应、永久禁用 | takePicture() 不返回也不抛错 |
加 8s 超时兜底 |
| iOS 上传报 90683 | 缺 NSxxxUsageDescription |
补齐隐私描述键 |
| iOS 上传报 409 | 链接器问题 | 改用 -ld_classic |
| iOS 媒体加载失败 | ATS 拦截 http | 后端改 https(或配 ATS 例外,不推荐) |
| Android 通知重复弹 | 多通道重复触发 | 按 messageId 幂等 + 冷却窗口 |
| Web 平台编译失败 | 部分插件 interop 不兼容 | 本地 patch + dependency_overrides |
| 国内市场上架被拒 | 缺软著 / APP 备案 | 提前启动,备案主体与域名 ICP 一致 |
七、总结
Flutter 做双端的收益是真实的,但收益集中在 UI 和业务逻辑层。真正决定项目能不能按时交付的,是这几件事:
- 平台差异从第一天就收口,不要等后期再抽。
- 老安卓机当作目标设备来设计,所有原生能力调用都要有 try/catch 和超时兜底。
- 合规前置------软著、备案、隐私清单这些周期长且卡在最后一步,开发中期就要启动。
- 推送是国内 Android 的深水区,提前定好多通道去重方案。
如果重来一次,我会把「上架合规 checklist」在立项时就放进项目计划,而不是等开发完了才发现软著还没申请。