ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter整包自动更新实战:原生主导架构与全平台适配

Flutter整包自动更新实战:原生主导架构与全平台适配

1. 项目概述:为什么Flutter App自动更新不是“加个插件就完事”的事

“Flutter APP自动更新”这六个字,最近在技术群、论坛和招聘JD里出现频率高得反常。但凡做过两个以上上线项目的开发者都清楚,它根本不是一句口号,而是一整套涉及用户留存、版本管理、灰度发布、崩溃防护甚至法律合规的系统工程。我带过的几个团队里,有团队用官方推荐的flutter_app_update包,结果上线后用户反馈“点了更新没反应”,排查三天发现是Android 11+对存储权限的限制没处理;也有团队自己封装原生更新逻辑,却在iOS审核时被拒,理由是“未提供明确的跳转App Store入口”。这些坑,都不是文档里写明的,而是真正在用户手机上跑起来之后才暴露的。

核心关键词——Flutter、自动更新、热更新、静默更新、强制更新、版本检测、下载安装、权限适配、iOS审核——每一个词背后都连着一串必须亲手踩过的雷。它适合谁?不是刚学完StatefulWidget的新手,而是已经发布过至少一个真实上线App、遇到过“发版后发现线上Bug要紧急修复”这类场景的中阶开发者;也适合技术负责人,需要评估一套更新方案能否支撑未来三年的用户量增长和合规要求。它解决的绝不仅是“让用户装上新版本”这个表层问题,而是“如何在不打扰用户、不触发审核风险、不引发批量崩溃的前提下,把正确版本、正确代码、正确资源,以可控节奏,推送到目标设备”。

很多人误以为Flutter跨平台就能“一次开发,处处更新”,实际上恰恰相反:因为Flutter的渲染引擎和业务逻辑都打包在APK/IPA里,它的更新比原生更重——你不能像Web那样只替换JS文件,必须重新下载整个Dart AOT产物+资源包。这就决定了它的更新策略必须更谨慎:什么时候该提示?什么时候该静默?什么时候必须强制?提示文案怎么写才能降低用户流失率?后台接口怎么设计才能支持按渠道、按机型、按地域灰度?这些细节,直接决定你下个版本的次日留存是涨还是跌。接下来的内容,全部来自我们过去18个月在5个不同行业App(工具类、电商类、教育类、本地生活类、企业内部系统)中落地自动更新的真实经验,不讲虚的,只说你明天就能抄作业的操作。

2. 整体架构设计与方案选型:为什么放弃纯Dart方案,坚持“原生主导+Flutter协同”

2.1 三种主流技术路径的硬伤实测

市面上关于Flutter自动更新的方案,基本逃不出三类:纯Dart实现、Flutter插件桥接原生、完全原生控制+Flutter仅做UI展示。我们团队逐一对这三类做了压力测试和线上灰度,结论非常明确:纯Dart方案在生产环境不可行,Flutter插件方案需深度定制,原生主导才是唯一稳健选择。

  • 纯Dart方案(如package:upgrader):它本质只是个“检查器+弹窗UI”,所有下载、校验、安装逻辑全靠调用系统API。问题在于,Dart层无法直接操作Android的PackageInstaller或iOS的UIApplication.shared.openURL,必须通过MethodChannel桥接。而一旦桥接,它就不再是“纯Dart”了。更致命的是,它对Android 10+的分区存储(Scoped Storage)和iOS的ATS(App Transport Security)适配极弱。我们曾用它在Android 12设备上测试,下载完成后的APK文件存放在getExternalStorageDirectory(),结果安装时系统直接报错SecurityException: Permission denied——因为该路径已被废弃。这不是插件bug,是Dart层根本无法感知底层存储策略变更。

  • Flutter插件方案(如flutter_app_update):这是目前社区最常用的,它封装了原生的下载和安装流程。但问题出在“封装”二字上。它默认使用OkHttp(Android)和NSURLSession(iOS)下载,但没暴露出超时、重试、断点续传等关键参数。我们一个教育App在三四线城市弱网环境下,用户下载更新包平均失败率达37%,原因就是插件内置的超时时间只有30秒,而一个50MB的APK在2G网络下可能需要2分钟。改源码?可以,但每次Flutter SDK升级,插件都要重新适配,维护成本爆炸。

  • 原生主导方案(我们最终采用):核心思想是——让原生层负责所有高危、高权、高兼容性操作,Flutter层只做状态同步和UI渲染。具体分工如下:

    • Android端:由Java/Kotlin编写UpdateManager,负责版本检查(调用后台接口)、APK下载(用WorkManager保证后台稳定下载)、文件校验(SHA256比对)、权限申请(动态请求REQUEST_INSTALL_PACKAGES)、安装触发(PackageInstallerAPI);
    • iOS端:由Swift/Objective-C编写UpdateService,负责版本检查、跳转App Store链接(https://apps.apple.com/app/id{APP_ID})、安装状态监听(通过UIApplication.openURL回调);
    • Flutter层:仅通过MethodChannel接收原生层推送的更新状态(如CHECKING、DOWNLOADING(30%)、INSTALLING、FAILED),并驱动UI组件(如进度条、按钮状态、提示文案)。

提示:这个分工不是为了炫技,而是基于一个铁律——系统级操作的容错率,永远低于业务逻辑层。下载失败可以重试,安装失败可以引导用户手动操作,但如果你在Dart层尝试用dart:io去写一个APK文件到Android私有目录,然后又试图用Process.start去调用pm install,那等待你的只会是Permission denied和用户卸载App。

2.2 为什么必须放弃“热更新”幻想,专注“整包更新”

搜索“Flutter 自动更新”,很多文章会顺带提一句“结合热更新更完美”。这里必须泼一盆冷水:在当前Flutter生态和主流应用商店政策下,Flutter热更新(即不发新APK/IPA,只下发Dart代码包)是伪命题,且存在重大合规风险。

热更新依赖的核心是flutter build bundle生成的.flx或.app资源包,再通过自定义加载器替换运行时Dart代码。但问题在于:

  • Android侧:Google Play明确禁止应用在不通过商店审核的情况下,动态加载可执行代码(Policy 4.3)。即使你走国内安卓市场,华为、小米、OPPO等厂商的审核指南里也写着“禁止应用从非官方渠道下载并执行代码”。我们曾为某工具App尝试接入第三方热更新SDK,结果在华为应用市场初审就被打回,理由是“检测到动态代码加载行为”。
  • iOS侧:Apple审核指南4.2.2条白纸黑字:“Apps that download code in any way or form will be rejected”。任何试图在运行时eval字符串、Function.apply动态函数、或从远程加载.dart.js的行为,都会导致上架失败。我们一个电商App曾因在Flutter Web版用了import('xxx.dart.js')动态导入,被苹果拒审三次。
  • 技术现实:Flutter的AOT编译产物是高度优化的机器码,Dart VM在Release模式下根本不加载源码。所谓“热更新Dart代码”,实际是绕过AOT,强制切回JIT模式——这会导致包体积暴涨30%、启动时间延长2倍、内存占用翻番。我们实测过,一个原本15MB的APK,开启JIT热更新后变成22MB,首屏渲染延迟从800ms拉长到2.1s,用户流失率直接上升11%。

所以,我们团队的共识是:把精力100%聚焦在“整包自动更新”的体验打磨上,而不是追逐一个既不合规、也不稳定、还损害性能的幻影。真正的“快”,不是代码下发快,而是用户从看到提示到用上新功能的总耗时短。一个5秒内完成下载安装的整包更新,远胜于一个需要30秒加载、还可能崩溃的热更新。

2.3 架构图:三层解耦,各司其职

整个自动更新系统的数据流和控制流,严格遵循“原生驱动、Flutter响应、服务端策应”原则。下图是我们在生产环境使用的简化架构:

+---------------------+ HTTP/HTTPS +---------------------+ | 服务端 |<------------------>| 原生层 (Android/iOS) | | - 版本元数据API | (JSON响应) | - 检查版本 | | - APK/IPA下载地址 | | - 下载APK/IPA | | - 灰度规则引擎 | | - 校验SHA256 | | - 强制更新开关 | | - 触发安装/跳转 | +----------+--------+ +----------+----------+ | | | MethodChannel (双向) | | | +----------v----------------------------------------v----------+ | Flutter层 | | - UpdateProvider (状态管理) | | - UpdateButton (点击触发检查) | | - UpdateDialog (提示弹窗,含进度条、文案、按钮) | | - UpdateStatusListener (监听原生层推送的状态变更) | +----------------------------------------------------------------+

关键设计点:

  • 状态驱动而非事件驱动:Flutter层不主动调用“开始下载”,而是监听UpdateStatusListener。当原生层状态变为DOWNLOADING,Flutter自动显示进度条;变为INSTALLING,按钮置灰并显示“正在安装…”。这样避免了Flutter和原生状态不同步的常见Bug(比如用户狂点按钮,原生还在下载,Flutter却已进入安装态)。
  • 服务端强管控:所有决策逻辑(是否提示、是否强制、下载地址、灰度比例)都由服务端返回。Flutter和原生层只做执行者。这样,运营同学在后台调整一个开关,就能立刻影响全量用户,无需发版。
  • 无单点故障:如果原生层崩溃(如AndroidPackageInstaller初始化失败),Flutter层会收到ERROR状态,并降级为“打开下载链接”;如果服务端不可用,原生层会使用本地缓存的上一版更新策略(如“7天内最多提示3次”),保证基础可用性。

3. 核心细节解析与实操要点:从权限申请到安装验证,一个都不能少

3.1 Android端:权限、存储、安装,三座大山怎么翻

Android的自动更新,本质是在和系统权限博弈。从Android 6.0到13,每一代都在收紧安装权限,我们必须步步为营。

第一步:动态申请REQUEST_INSTALL_PACKAGES权限(Android 8.0+必需)
这不是普通运行时权限,它不会出现在<uses-permission>里,而是一个“特殊权限”,必须引导用户手动开启。代码不能简单调用requestPermissions,而要先判断权限状态:

// Kotlin - Android端权限检查 private fun checkInstallPermission(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return packageManager.canRequestPackageInstalls() } return true // Android 7.1及以下无需此权限 } // 如果未授权,跳转到设置页 if (!checkInstallPermission()) { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) intent.data = Uri.parse("package:$packageName") startActivityForResult(intent, REQUEST_CODE_INSTALL_PERMISSION) }

注意:ACTION_MANAGE_UNKNOWN_APP_SOURCES在Android 8.0引入,但直到Android 11才成为强制项。我们在线上埋点发现,约12%的Android 9/10用户会忽略这个设置,导致更新失败。解决方案是——在检查版本前,先静默检查一次权限,如果未授权,立即弹出轻量级Toast:“请开启安装未知来源应用权限,否则无法更新”,而不是等到下载完成才提示,那时用户已经失去耐心。

第二步:下载路径必须适配分区存储(Android 10+)
getExternalStorageDirectory()在Android 10已被废弃。我们必须将APK下载到应用专属目录(context.getExternalFilesDir(null)),这个目录无需额外权限,且卸载App时自动清理。但要注意:getExternalFilesDir返回的路径,在Android 11+上是/storage/emulated/0/Android/data/{package}/files/,而PackageInstaller要求APK路径必须是content://URI。因此,必须通过FileProvider转换:

<!-- res/xml/file_paths.xml --> <paths> <external-files-path name="external_files_path" path="."/> </paths>
// 下载完成后,生成Content URI val apkFile = File(context.getExternalFilesDir(null), "app-release.apk") val contentUri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", apkFile )

实操心得:我们曾因忘记在AndroidManifest.xml中声明<provider>,导致Android 12设备上安装始终失败,错误日志只显示java.lang.SecurityException: Permission Denial,排查了两天才发现是FileProvider配置缺失。建议把file_paths.xml和<provider>声明作为模板,所有新项目直接复制。

第三步:安装触发必须兼容全版本API
Android的安装API从Intent到PackageInstaller再到PackageInstaller.Session,演进复杂。我们的方案是分层兼容:

  • Android 7.0以下:Intent(Intent.ACTION_VIEW).setDataAndType(uri, "application/vnd.android.package-archive")
  • Android 7.0-9.0:同上,但需grantUriPermission授予临时读取权限
  • Android 10+:必须用PackageInstaller创建Session,写入APK流,再提交安装
// Android 10+ 安装核心逻辑 val packageInstaller = context.packageManager.packageInstaller val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) sessionParams.setAppPackageName(context.packageName) val sessionId = packageInstaller.createSession(sessionParams) val session = packageInstaller.openSession(sessionId) val inputStream = FileInputStream(apkFile) val outputStream = session.openWrite("app-release", 0, -1) inputStream.copyTo(outputStream) session.fsync(outputStream) outputStream.close() inputStream.close() // 提交安装 session.commit(PendingIntent.getBroadcast(context, 0, Intent(context, InstallReceiver::class.java), 0).intentSender)

关键细节:session.commit()必须传入PendingIntent,用于接收安装结果。我们自定义InstallReceiver,在onReceive里解析Intent的extra,获取STATUS_PENDING_USER_ACTION(需用户确认)或STATUS_SUCCESS(静默成功)。这个Receiver必须在AndroidManifest.xml中静态注册,否则无法接收系统广播。

3.2 iOS端:没有“安装”,只有“跳转”,但文案和时机决定成败

iOS的自动更新,技术上极其简单:调用UIApplication.shared.openURL打开App Store链接。但用户体验的差距,全在细节里。

核心原则:绝不自动跳转,只提供清晰、低干扰的入口
Apple审核指南严禁应用未经用户明确同意就跳转外部应用。我们见过太多App,一检测到新版本就openURL,用户正在输入收货地址,屏幕突然切到App Store,体验极差。我们的做法是:

  • 首次检测到更新,只显示一个底部Banner:“新版本已发布,修复了XX问题,提升稳定性。[立即更新]”;
  • 用户点击“立即更新”后,再弹出确认弹窗:“即将跳转至App Store更新应用,更新后可继续使用。是否前往?”;
  • Banner和弹窗的文案,必须包含具体收益,如“修复了拍照闪退问题”、“优化了首页加载速度”,而不是空洞的“新版本上线”。

技术实现:深链(Deep Link)提升转化率
直接跳https://apps.apple.com/app/id{APP_ID},用户会看到App Store首页,需要二次搜索。我们使用Apple官方支持的itms-apps://协议,并拼接mt=8参数,确保直达应用页:

// Swift - iOS端跳转 func openAppStore() { guard let url = URL(string: "itms-apps://apps.apple.com/app/id\(appID)?mt=8") else { return } if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:]) { success in if !success { // 打开失败,降级为SFSafariViewController self.openInSafari(url) } } } }

注意:canOpenURL在iOS 9+需要在Info.plist中声明LSApplicationQueriesSchemes,添加itms-apps。否则canOpenURL返回false,用户点击无反应。这个配置极易遗漏,建议把它写进新项目Checklist。

安装状态监听:用UIApplication.willEnterForegroundNotification间接感知
iOS没有安装完成回调,但我们可以通过监听App从后台回到前台的时机,来推测用户是否完成了更新。逻辑是:

  • 用户点击“前往App Store”前,记录当前版本号和时间戳到UserDefaults;
  • 监听willEnterForegroundNotification,App唤醒后,读取当前版本号;
  • 如果版本号变化,且距离上次跳转时间小于10分钟,则认为是“刚更新完成”,此时可弹出欢迎页:“感谢更新!本次新增了XX功能”。

这个方案虽非100%精准(用户可能只是切出去看微信),但线上数据显示,83%的更新用户会在更新后5分钟内重新打开App,足以覆盖绝大多数场景。

3.3 Flutter层:状态管理、UI交互、异常兜底,一个都不能软

Flutter层看似只是“画皮”,实则是用户体验的最终呈现者。我们用Provider做状态管理,因为它轻量、易测试、且与Widget树天然契合。

状态模型设计:用枚举+数据类精准描述每一种可能
我们定义了一个UpdateStatus密封类,覆盖所有状态:

enum UpdateCheckResult { noUpdate, hasUpdate, checking, error } class UpdateStatus { final UpdateCheckResult result; final String? latestVersion; // 最新版本号 final String? updateMessage; // 更新文案,如“修复了登录异常” final bool isForced; // 是否强制更新 final double? downloadProgress; // 下载进度 0.0~1.0 final String? errorMessage; // 错误信息 const UpdateStatus._({ required this.result, this.latestVersion, this.updateMessage, this.isForced = false, this.downloadProgress, this.errorMessage, }); factory UpdateStatus.noUpdate() => UpdateStatus._(result: UpdateCheckResult.noUpdate); factory UpdateStatus.hasUpdate({required String version, required String message, bool isForced = false}) => UpdateStatus._(result: UpdateCheckResult.hasUpdate, latestVersion: version, updateMessage: message, isForced: isForced); factory UpdateStatus.checking() => UpdateStatus._(result: UpdateCheckResult.checking); factory UpdateStatus.error({required String message}) => UpdateStatus._(result: UpdateCheckResult.error, errorMessage: message); factory UpdateStatus.downloading(double progress) => UpdateStatus._(result: UpdateCheckResult.checking, downloadProgress: progress); }

为什么不用String或int表示状态?因为enum能防止状态错乱。比如,downloadProgress只应在checking状态下存在,如果用Map<String, dynamic>,很容易在noUpdate状态下误传progress,导致UI渲染异常。用Dart的密封类,编译期就能捕获这类错误。

UI组件:一个UpdateButton搞定所有交互场景
我们封装了一个UpdateButtonWidget,它根据UpdateStatus自动切换样式和行为:

class UpdateButton extends StatelessWidget { @override Widget build(BuildContext context) { final status = context.watch<UpdateProvider>().status; if (status.result == UpdateCheckResult.noUpdate) { return Container(); // 不显示 } if (status.result == UpdateCheckResult.checking) { return _buildCheckingButton(status); } if (status.result == UpdateCheckResult.hasUpdate) { return _buildUpdateButton(status); } if (status.result == UpdateCheckResult.error) { return _buildErrorButton(status); } return Container(); } Widget _buildUpdateButton(UpdateStatus status) { return ElevatedButton( onPressed: () { // 调用MethodChannel触发原生更新 context.read<UpdateProvider>().triggerUpdate(); }, child: Text(status.isForced ? '立即更新(强制)' : '立即更新'), ); } Widget _buildCheckingButton(UpdateStatus status) { if (status.downloadProgress != null) { return SizedBox( width: 120, height: 40, child: LinearProgressIndicator(value: status.downloadProgress), ); } return const CircularProgressIndicator(); } }

实操心得:这个Button的onPressed里,我们绝不直接调用MethodChannel.invokeMethod,而是通过Provider的triggerUpdate()方法。这样,所有业务逻辑(如埋点上报、防抖、状态重置)都集中在Provider里,Button本身保持纯净,复用性极高。我们一个内部工具App,同一个UpdateButton被用在首页、个人中心、设置页三个位置,零修改。

异常兜底:当原生层失联,Flutter必须优雅降级
网络抖动、原生插件崩溃、MethodChannel通信失败,都是常态。我们的兜底策略是:

  • 在UpdateProvider里,为每个MethodChannel调用设置3秒超时;
  • 超时后,自动降级为“打开下载链接”(Android)或“跳转App Store”(iOS);
  • 同时,上报一条update_fallback埋点,记录降级原因,用于后续优化。
Future<void> triggerUpdate() async { try { final result = await _channel.invokeMethod('startUpdate', <String, dynamic>{}); // 处理成功 } on PlatformException catch (e) { // 原生层异常,降级 _handleFallback(e.code); } on TimeoutException { // 超时,降级 _handleFallback('timeout'); } }

这个兜底机制,让我们线上更新失败率从最初的8.2%降到0.7%,绝大部分用户甚至感知不到“失败”,只是多了一次点击。

4. 实操过程与核心环节实现:从零开始,手把手搭建可上线的更新系统

4.1 环境准备与依赖配置:一步到位,拒绝“缺啥补啥”

在开始编码前,必须完成三件事,它们决定了后续80%的顺利程度。

第一步:配置Android端FileProvider(5分钟)
这是Android 7.0+的刚需,漏掉它,整个更新流程在半路就会崩。

  1. 在android/app/src/main/res/xml/下创建file_paths.xml:
<?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="external_files_path" path="."/> <!-- 这行必须有,指向getExternalFilesDir --> </paths>
  1. 在android/app/src/main/AndroidManifest.xml的<application>节点内,添加<provider>:
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
  1. 在android/app/build.gradle的defaultConfig里,确认applicationId已设置(通常已有):
defaultConfig { applicationId "com.example.myapp" // ... 其他配置 }

验证方法:运行App,用ADB命令查看Provider是否注册成功:adb shell dumpsys package com.example.myapp | grep fileprovider。如果看到输出,说明配置正确。

第二步:配置iOS端Info.plist(3分钟)
iOS端配置更简单,但同样关键。

  1. 在ios/Runner/Info.plist中,添加LSApplicationQueriesSchemes,允许查询itms-apps:
<key>LSApplicationQueriesSchemes</key> <array> <string>itms-apps</string> </array>
  1. 添加NSAppTransportSecurity,允许HTTP(如果后台API是HTTP):
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>

注意:NSAllowsArbitraryLoads仅用于开发测试,上线前必须移除,改为针对你的后台域名配置NSExceptionDomains。

第三步:添加Flutter依赖(2分钟)
在pubspec.yaml中,添加我们自研的轻量级通信包(替代method_channel的繁琐样板):

dependencies: flutter: sdk: flutter # 我们封装的更新通信包,已开源 flutter_update_bridge: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter

然后运行flutter pub get。这个包内部已处理好MethodChannel的注册、类型安全的参数传递、以及超时重试,你只需关注业务逻辑。

4.2 服务端接口设计:一个JSON,撑起所有策略

自动更新的智能,90%在服务端。我们设计了极简但强大的/api/v1/update/check接口。

请求方式:GET
Header:Authorization: Bearer {token}(可选,用于识别用户)
Query Params:

  • platform:android或ios
  • version_code: Android的versionCode(整数),如102
  • version_name: Android的versionName或iOS的CFBundleShortVersionString,如2.3.1
  • device_id: 设备唯一标识(MD5或UUID),用于灰度

成功响应(HTTP 200):

{ "code": 0, "message": "success", "data": { "has_update": true, "latest_version": "2.4.0", "update_message": "【重要更新】修复了支付成功率低的问题,优化了首页加载速度。", "is_forced": false, "download_url": "https://cdn.example.com/app/android/app-release-2.4.0.apk", "sha256": "a1b2c3...f8e9d0", "size": 48234567, "min_sdk": 21, "force_deadline": "2023-12-31T23:59:59Z" } }

关键字段解析:

  • is_forced:true时,Flutter UI必须显示“强制更新”按钮,且点击后禁用返回键;
  • force_deadline: 强制更新的截止时间,客户端需校验,如果当前时间超过此时间,即使is_forced为false,也必须强制;
  • min_sdk: Android最低支持SDK版本,客户端需校验,如果当前设备Build.VERSION.SDK_INT < min_sdk,则不提示更新(避免用户更新后无法运行);
  • sha256: 用于APK下载完成后校验完整性,防止传输损坏或中间人篡改。

实操心得:这个接口必须支持按设备ID灰度。我们用Redis做布隆过滤器(Bloom Filter),将device_id哈希后取模,如果余数在[0, 9](即10%),则返回has_update: true,否则返回false。这样,运营同学只需改一个百分比数字,就能控制灰度范围,无需动代码。

4.3 原生层开发:Android与iOS,各自的核心代码

Android端:UpdateManager核心逻辑(Kotlin)
在android/app/src/main/java/com/example/myapp/下创建UpdateManager.kt:

class UpdateManager(private val context: Context) { private val packageManager = context.packageManager private val httpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build() fun checkUpdate(versionCode: Int, versionName: String, deviceId: String, callback: (UpdateResponse) -> Unit) { // 1. 构造请求URL val url = "https://api.example.com/api/v1/update/check?" + "platform=android&version_code=$versionCode&version_name=$versionName&device_id=$deviceId" // 2. 发起网络请求 val request = Request.Builder().url(url).build() httpClient.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { callback(UpdateResponse.error(e.message ?: "网络错误")) } override fun onResponse(call: Call, response: Response) { if (response.isSuccessful && response.body != null) { val json = JSONObject(response.body!!.string()) val code = json.getInt("code") if (code == 0) { val data = json.getJSONObject("data") val hasUpdate = data.getBoolean("has_update") if (hasUpdate) { val latestVersion = data.getString("latest_version") val message = data.getString("update_message") val isForced = data.optBoolean("is_forced", false) val downloadUrl = data.getString("download_url") val sha256 = data.getString("sha256") callback(UpdateResponse.hasUpdate(latestVersion, message, isForced, downloadUrl, sha256)) } else { callback(UpdateResponse.noUpdate()) } } else { callback(UpdateResponse.error(json.getString("message"))) } } else { callback(UpdateResponse.error("HTTP ${response.code()}")) } } }) } fun downloadAndInstall(downloadUrl: String, sha256: String, callback: (DownloadResult) -> Unit) { // 使用WorkManager保证后台下载稳定 val inputData = Data.Builder() .putString("download_url", downloadUrl) .putString("expected_sha256", sha256) .build() val workRequest = OneTimeWorkRequestBuilder<DownloadWorker>() .setInputData(inputData) .build() WorkManager.getInstance(context).enqueue(workRequest) } }

DownloadWorker是一个继承CoroutineWorker的后台任务,负责下载、校验、触发安装。由于篇幅,此处不展开,但核心是:必须用WorkManager,而不是Thread或AsyncTask,因为后者在App退到后台后会被系统杀死。

iOS端:UpdateService核心逻辑(Swift)
在ios/Runner/下创建UpdateService.swift:

class UpdateService { static let shared = UpdateService() private init() {} func checkUpdate(platform: String, versionName: String, deviceId: String, completion: @escaping (UpdateResponse) -> Void) { guard let url = URL(string: "https://api.example.com/api/v1/update/check?platform=ios&version_name=\(versionName)&device_id=\(deviceId)") else { completion(.error("URL构建失败")) return } URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { completion(.error(error.localizedDescription)) return } guard let data = data else { completion(.error("空响应")) return } do { let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] guard let code = json?["code"] as? Int, code == 0 else { let message = (json?["message"] as? String) ?? "未知错误" completion(.error(message)) return } guard let dataObj = json?["data"] as? [String: Any] else { completion(.error("数据格式错误")) return } guard let hasUpdate = dataObj["has_update"] as? Bool, hasUpdate else { completion(.noUpdate()) return } let latestVersion = dataObj["latest_version"] as? String ?? "" let message = dataObj["update_message"] as? String ?? "" let isForced = dataObj["is_forced"] as? Bool ?? false completion(.hasUpdate(latestVersion: latestVersion, message: message, isForced: isForced)) } catch { completion(.error("JSON解析失败")) } }.resume() } func openAppStore() { guard let appID = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String else { print("获取Bundle ID失败") return } let urlStr = "itms-apps://apps.apple.com/app/id\(appID)?mt=8" if let url = URL(string: urlStr) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else { // 降级 if let fallbackUrl = URL(string: "https://apps.apple.com/app/id\(appID)") { UIApplication.shared.open(fallbackUrl) } } } } }

注意:CFBundleIdentifier就是你在Xcode中设置的Bundle ID,它和App Store上的ID一致,所以可以直接拼接。这是iOS端最稳妥的跳转方式。

4.4 Flutter层集成:Provider、Widget、MethodChannel,三步串联

第一步:创建UpdateProvider(Dart)
在lib/providers/update_provider.dart:

class UpdateProvider with ChangeNotifier { final MethodChannel _channel = const MethodChannel('com.example.myapp/update'); UpdateStatus _status = UpdateStatus.noUpdate(); UpdateStatus get status => _status; void setStatus(UpdateStatus newStatus) { _status = newStatus; notifyListeners(); } Future<void> checkUpdate() async { try { setStatus(UpdateStatus.checking()); final result = await _channel.invokeMethod('checkUpdate', <String, dynamic>{ 'platform': defaultTargetPlatform == TargetPlatform.android ? 'android' : 'ios', 'version_code': _getAndroidVersionCode(), 'version_name': _getVersionName
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进