Flutter 双端开发实战:一套代码搞定 iOS + Android,从开发到上架全流程

我们团队用 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

三条实操原则:

  1. 业务层不出现 Platform 判断。 需要差异的地方,抽到 services/utils/ 里做成统一接口,业务层只调接口。
  2. 老安卓机是一等公民。 国内存量设备里低端机占比不低,我们的目标机里就有老安卓。所有"吃资源"的操作(大图解码、视频截帧、批量压缩)都必须有降级路径,宁可功能弱一点,也不能卡死或崩。
  3. Web 只当预览用。 flutter run -d chrome 很方便,但别把它当交付目标。蓝牙、iBeacon、写系统相册、视频截帧这类能力在 Web 上要么没有、要么需要额外规避(我们就遇到 get_video_thumbnail 0.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 冷却防消息轰炸)。


四、性能:老安卓机不卡死是底线

几条我们验证有效的规则:

  1. 能加 const 就加。 全量 flutter analyzeprefer_const_constructors 是我们重点清理的一类------重建是老机型卡顿的主因。
  2. 避免整页 setState 局部刷新的地方拆成独立 Widget 或用 ValueNotifier
  3. 图片必须压缩。 选图时 imageQuality: 90,批量处理前先按目标尺寸采样,别直接解码几十张原图。
  4. 耗时操作放 compute() 水印合成、批量缩放这类 CPU 密集任务走独立 isolate,避免掉帧。
  5. 列表用 ListView.builder ,禁止一次性 Column(children: [...]) 渲染长列表。

五、打包与上架:把合规前置到开发阶段

5.1 Android

bash 复制代码
# 正式包
flutter build apk --release --split-per-abi
flutter build appbundle --release

几个必查项:

  • 签名key.properties 别提交进仓库,build.gradle 里读取。
  • 明文流量AndroidManifest.xmlandroid:usesCleartextTraffic="false",强制 https。
  • 权限瘦身 :用不到的权限全删(我们删掉了未使用的 READ_PHONE_STATE,部分市场会因此拒审)。
  • targetSdk:跟进各市场的最新要求,别卡在旧版本。

国内上架的两个前置条件,很多人会漏:

  1. 软件著作权(下证周期长,建议开发中期就启动)
  2. 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-notificationbluetooth-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 和业务逻辑层。真正决定项目能不能按时交付的,是这几件事:

  1. 平台差异从第一天就收口,不要等后期再抽。
  2. 老安卓机当作目标设备来设计,所有原生能力调用都要有 try/catch 和超时兜底。
  3. 合规前置------软著、备案、隐私清单这些周期长且卡在最后一步,开发中期就要启动。
  4. 推送是国内 Android 的深水区,提前定好多通道去重方案。

如果重来一次,我会把「上架合规 checklist」在立项时就放进项目计划,而不是等开发完了才发现软著还没申请。

相关推荐
Zeaon3 小时前
别再手写筛选栏了:一个可组合的 Flutter 选择组件(5 入口 × 7 委托)
android·flutter
事圆则缓3 小时前
Flutter 快速上手
flutter
「、皓子~8 小时前
海狸IM 2.1 私有化部署从零到一:中间件、微服务与 Nginx 全流程
nginx·flutter·微服务·中间件·开源软件·im·海狸im
嘴贱欠吻!9 小时前
Flutter 触觉反馈插件 Gaimon三方库鸿蒙端教程
flutter·华为·harmonyos
getapi10 小时前
1 在 macOS 电脑上更新 Flutter iOS 应用到 App Store
flutter·macos·ios
恋猫de小郭10 小时前
Flutter 状态管理基准测评,一个很有趣的观点
android·前端·flutter
●VON10 小时前
Flutter 鸿蒙插件适配实战:给 screen_security 补上截图与录屏防护
flutter·华为·harmonyos
tbit1 天前
Android Google Map PlatformView 图层异常排查与修复
flutter
哈__1 天前
Flutter 3.44.9 + OpenHarmony7:home_widget 三方库桌面服务卡片(FormKit)的应用
flutter·华为·harmonyos