十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter鸿蒙应用自建更新检测完整实践:从版本比较到下载安装与深色适配

Flutter鸿蒙应用自建更新检测完整实践:从版本比较到下载安装与深色适配 手头这个Flutter项目要上鸿蒙设备的时候排期里最简单却最绕不开的一件事就是把应用更新检测做了。过去在Android上版本升级基本都是应用商店托底开发者只需要把新包传上去但鸿蒙的应用分发路径不止一条除了应用市场还有企业签名包、定向安装、官网下载等场景。这些路径下应用根本没有“商店自动提醒”这回事用户拿到的老版本只能靠应用自己发现新版本、自己下载、自己引导安装。这篇文章就是把我在Flutter鸿蒙工程里集成更新检测的完整过程记录下来包括接口设计、版本比较、下载安装、深色模式弹窗适配这些环节适合正在做Flutter鸿蒙适配、需要自己实现升级流程的开发者参考。整个过程不依赖额外的热更新SDK接口自建更新包走系统安装器逻辑清晰可控。1. 项目背景与更新检测方案选型1.1 为什么在鸿蒙设备上需要自建更新检测鸿蒙生态的应用分发情况比Android要复杂一些。应用市场覆盖的是C端常规用户但企业内部应用、政企定制、IoT设备预装这类场景通常走的是签名包直接安装的路线。应用装到用户设备上之后版本升级就成了一个悬而未决的问题——没有商店的“更新提醒”能力用户可能一直停留在老版本上新功能上线了也感知不到。更现实的一点是鸿蒙应用包的安装格式是HAP包它不像Android的APK那样可以随意从浏览器下载并触发安装系统对安装来源和权限管控得更细。也就是说应用内更新不能只做一个“下载完就静默安装”的动作必须引导用户经过系统安装确认流程。这也是为什么更新检测功能在鸿蒙上比在其他平台更需要提前设计好交互路径。Flutter在鸿蒙上的适配由OpenHarmony社区维护的一套fork分支推进核心框架层面的大多数能力可以复用但和系统侧交互的部分比如获取版本号、拉起安装页面仍然需要通过MethodChannel桥接到鸿蒙原生能力。好在鸿蒙提供了完整的能力接口获取应用自身信息和启动Want都不难关键是要把这一层的桥接封装做好。1.2 方案选型自建接口检测与市场托管的取舍接更新检测之前我先把可选的方案列了一遍各有各的适用场景方案优点缺点适用场景应用市场托管更新安装、提醒、回滚全托管只覆盖市场分发渠道企业包无法使用上架应用市场的C端应用自建接口本地下载安装渠道全覆盖、可控性强、可做灰度需要自己维护版本接口和下载服务企业分发、定向安装、混合分发第三方热更新SDK集成简单、带统计后台额外SDK体积部分能力不开放团队人力紧张、需要快速上线我最终选了自建接口本地下载安装这条路。原因很简单项目既有市场渠道也有大量定向安装的设备更新逻辑必须掌握在自己手里。另外自建接口的灵活性是托管方案给不了的——比如可以按渠道下发不同版本、按设备号做灰度、紧急情况下远程关闭更新入口这些都是实际运营中可能出现的需求。下载安装环节不接入SDK直接下载HAP包后拉起鸿蒙的系统安装确认页。这样做的最大好处是规避了第三方SDK在鸿蒙适配上的不确定性坏处是下载进度、失败重试、安装引导这些细节都要自己处理。正好借着这次集成把整个链路完整走了一遍。2. 更新检测核心逻辑拆解2.1 客户端版本号的获取更新检测的第一步是精确拿到当前安装包的版本号。这里需要注意一个细节版本判断不能依赖versionName也就是“1.0.1”这种字符串因为字符串比较很容易出边界问题比如“1.10.0”和“1.9.0”用字符串比较会得出错误结论。应该用versionCode这种递增的整数在鸿蒙上对应的是bundleInfo里的versionCode字段。Flutter侧无法直接读取鸿蒙应用的版本信息package_info_plus插件在鸿蒙上的支持还不够稳定。我这里直接用MethodChannel写了一个原生通道大概步骤如下。Flutter侧定义通道import package:flutter/services.dart; class NativeChannel { static const MethodChannel _channel MethodChannel(com.example.app/native); /// 获取当前应用的 versionCode static Futureint getVersionCode() async { final int code await _channel.invokeMethod(getVersionCode); return code; } /// 获取当前应用的 versionName static FutureString getVersionName() async { final String name await _channel.invokeMethod(getVersionName); return name; } }鸿蒙原生侧在Flutter插件注册时处理这个通道用bundleManager读取应用自身信息import { bundleManager } from kit.AbilityKit; import { hilog } from kit.PerformanceAnalysisKit; // 在插件 onAttach 或 UIAbility 的 onWindowStageCreate 中注册 const channel flutterEngine.getBinaryMessenger(); const methodChannel new MethodChannel(channel, com.example.app/native); methodChannel.setMethodCallHandler((call, result) { if (call.method getVersionCode) { const bundleInfo bundleManager.getBundleInfoForSelf( bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT ); result.success(bundleInfo.versionCode); } else if (call.method getVersionName) { const bundleInfo bundleManager.getBundleInfoForSelf( bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT ); result.success(bundleInfo.versionName); } });这段代码是ArkTS简化写法实际项目里要根据Flutter鸿蒙适配版本的插件注册方式微调。但思路是一致的Flutter只负责发起调用取值逻辑放在原生侧。原生返回的versionCode就是数字Flutter侧拿到后直接用于比较逻辑最稳。2.2 检测接口的参数与响应设计版本号拿到后下一步就是请求服务端接口把当前版本信息传过去让服务端判断是否需要更新。这个接口的请求参数和响应结构直接影响后续所有逻辑的复杂度建议一开始就设计得清晰一点。我这边接口设计如下POST /api/v1/app/check_update { appId: com.example.app, platform: harmony, versionCode: 10011, channel: enterprise, deviceId: 3fa85f64-5717-4562-b3fc-2c963f66afa6 }字段含义参数类型说明appIdstring应用唯一标识platformstring平台标识这里固定为harmonyversionCodeint客户端当前versionCodechannelstring渠道标识用于灰度分发deviceIdstring设备唯一标识用于指定设备灰度服务端响应{ code: 0, message: ok, data: { hasUpdate: true, versionCode: 10012, versionName: 1.0.12, title: 发现新版本, changelog: 新增深色模式适配\n修复若干问题, downloadUrl: https://cdn.example.com/app/10012/app_v1.0.12.hap, size: 104857600, md5: 5d41402abc4b2a76b9719d911017c592, force: false } }服务端比较逻辑很简单——用客户端传来的versionCode和服务端最新版本的versionCode做比较前者小于后者就有更新。具体灰度逻辑可以在服务端做例如指定渠道、指定设备白名单、按百分比放量。客户端不需要关心这部分只需要按接口返回的hasUpdate字段走就行。请求封装我用的是dio鸿蒙适配版本使用社区维护的分支基本API和标准dio一致final dio Dio(BaseOptions( baseUrl: https://api.example.com, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )); class UpdateApi { static FutureMapString, dynamic checkUpdate({ required int versionCode, required String channel, }) async { final resp await dio.post( /api/v1/app/check_update, data: { appId: com.example.app, platform: harmony, versionCode: versionCode, channel: channel, deviceId: await _getDeviceId(), }, ); final map resp.data as MapString, dynamic; return map[data] as MapString, dynamic; } }2.3 版本比较与服务端下发规则接口返回后客户端一般直接根据hasUpdate判断即可。但我在实际项目中养成了一个习惯即使服务端已经判断过了客户端本地也要再做一次versionCode比较兜底。这个兜底不是重复劳动而是防服务端逻辑出错——比如灰度配置错误、缓存数据过期、后端人员手动改了响应数据。本地做一层数字比较成本极低却能把“用户被错误提示引导去升级”的概率降到最低。bool shouldUpdate({ required int localVersionCode, required int serverVersionCode, required bool hasUpdate, }) { if (!hasUpdate) return false; return serverVersionCode localVersionCode; }版本号比较必须转成int再比较千万别直接用字符串比较。这是我踩过的一个实际坑。早期接口返回versionCode是字符串类型服务端是“1009”客户端是“10010”字符串一比较“1009”大于“10010”——因为“9”大于“1”结果把老版本用户全部判定成已是最新版本。服务端下发规则方面我比较推荐设计force字段。常规更新弹窗用户可以选择“以后再说”但如果版本涉及严重bug或者重大安全修复必须让用户强制升级否则后续功能没法用。force的判断一定放在服务端客户端只看字段不自己做“哪些版本必须强制”的判断。3. 深色模式联动与弹窗适配3.1 应用主题模式与系统深色模式联动更新检测不只后端逻辑前端弹窗同样有讲究。最典型的就是深色模式适配——鸿蒙系统从早期版本就全面推深色模式用户切到深色之后应用如果还弹一个白底弹窗视觉上会非常突兀。所以更新检测弹窗必须跟随系统主题切换。在Flutter里做深色模式联动核心是在MaterialApp上配置主题MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF4C6FFF), brightness: Brightness.light, ), dialogTheme: DialogThemeData( backgroundColor: Colors.white, surfaceTintColor: Colors.transparent, ), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF4C6FFF), brightness: Brightness.dark, ), dialogTheme: DialogThemeData( backgroundColor: const Color(0xFF1E1E1E), surfaceTintColor: Colors.transparent, ), ), themeMode: ThemeMode.system, )ThemeMode.system表示跟随系统当前是深色还是浅色。如果设置成ThemeMode.light或dark就会强制固定某一个主题这不符合“跟随系统”的需求。注意一点设置了theme和darkTheme后应用里的组件拿到的是对应亮度下的主题数据但如果某个页面自己硬编码了颜色主题切换就管不到它了。3.2 更新弹窗的自定义配色体系更新弹窗我建议不要用默认的AlertDialog而是自定义一个Dialog组件。原因有两个一是默认弹窗的样式在鸿蒙设备上表现不一致二是更新弹窗的交互元素比较多版本号、更新日志、进度条、按钮都有默认组件不好扩展。自定义更新弹窗的核心是颜色取值要从当前Theme里拿而不是写死class UpdateDialog extends StatelessWidget { final UpdateInfo info; final VoidCallback onUpdate; final VoidCallback? onLater; const UpdateDialog({ super.key, required this.info, required this.onUpdate, this.onLater, }); override Widget build(BuildContext context) { final scheme Theme.of(context).colorScheme; final isDark Theme.of(context).brightness Brightness.dark; return Dialog( backgroundColor: scheme.surface, surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( info.title, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: scheme.onSurface, ), ), const SizedBox(height: 8), Text( v${info.versionName}, style: TextStyle( fontSize: 14, color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 16), Text( info.changelog, style: TextStyle( fontSize: 14, height: 1.6, color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 24), Row( children: [ if (onLater ! null) ...[ Expanded( child: OutlinedButton( onPressed: onLater, style: OutlinedButton.styleFrom( foregroundColor: scheme.primary, side: BorderSide(color: scheme.outline), ), child: const Text(以后再说), ), ), const SizedBox(width: 12), ], Expanded( child: FilledButton( onPressed: onUpdate, style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, ), child: const Text(立即更新), ), ), ], ), ], ), ), ); } }这里的关键点在于所有颜色都从colorScheme里取而不是直接用Colors.white或Color(0xFF666666)。ColorScheme在亮色和暗色主题下会自动切换scheme.surface在亮色下是白色暗色下就是深灰色scheme.onSurface对应的文字颜色也会自动变。这样弹窗在任何主题下都不会出现白底黑字看不清的情况。3.3 状态栏与系统导航栏的适配弹窗弹出来之后还有个细节容易被忽略——状态栏图标的颜色。深色模式下系统状态栏图标默认是白色如果弹窗背景是深色就正常但如果应用页面本身是亮色、弹窗是深色状态栏图标和弹窗之间的视觉协调就需要注意了。Flutter侧可以用AnnotatedRegion来调整状态栏样式AnnotatedRegionSystemUiOverlayStyle( value: isDark ? SystemUiOverlayStyle.light // 深色背景配浅色图标 : SystemUiOverlayStyle.dark, // 浅色背景配深色图标 child: Material( color: scheme.surface, child: ..., ), )基本原则是背景越深状态栏图标颜色越浅背景越浅图标颜色越深。在实际项目中我把这个AnnotatedRegion直接放在了弹窗的顶层确保弹窗弹出时状态栏图标与弹窗背景匹配。否则深色模式下弹窗背景是深灰状态栏图标也是深灰就几乎看不到了。4. 更新检测完整流程实现4.1 触发时机与控制策略更新检测的实现不复杂真正复杂的是什么时候触发、什么时候不触发。如果每次打开App都弹更新弹窗用户会烦到直接卸载。我的常规策略是应用进入首页后延迟3秒再检测避免影响首屏加载用户点击“以后再说”后24小时内不再弹出forcetrue的强制更新不遵守24小时限制每次进入首页都弹检测到更新但下载失败的记录失败次数超过3次当天不再自动重试“以后再说”的时间戳我用shared_preferences存在本地class UpdateEntry { static const _ignoreKey update_ignore_time; static const _ignoreInterval Duration(hours: 24); static Futurebool shouldCheck() async { final prefs await SharedPreferences.getInstance(); final lastIgnore prefs.getInt(_ignoreKey) ?? 0; final now DateTime.now().millisecondsSinceEpoch; return now - lastIgnore _ignoreInterval.inMilliseconds; } static Futurevoid markIgnored() async { final prefs await SharedPreferences.getInstance(); await prefs.setInt(_ignoreKey, DateTime.now().millisecondsSinceEpoch); } }进入首页时的调用逻辑Futurevoid checkUpdateInBackground() async { await Future.delayed(const Duration(seconds: 3)); final localCode await NativeChannel.getVersionCode(); final resp await UpdateApi.checkUpdate( versionCode: localCode, channel: enterprise, ); // 先判断是否需要忽略 if (!resp[force] !await UpdateEntry.shouldCheck()) { return; } final info UpdateInfo.fromJson(resp); if (!shouldUpdate( localVersionCode: localCode, serverVersionCode: info.versionCode, hasUpdate: info.hasUpdate, )) { return; } if (!_isShowing) { _isShowing true; showDialog(...); } }这里还有一个全局标记_isShowing防止重复弹窗。用户可能因为页面跳转、进程恢复等原因触发多次检测同一时间只允许存在一个更新弹窗。4.2 更新弹窗交互实现更新弹窗的交互流程分两种情况普通更新和强制更新。普通更新showDialog( context: context, barrierDismissible: false, // 点击遮罩不关闭 builder: (context) UpdateDialog( info: info, onLater: () async { await UpdateEntry.markIgnored(); Navigator.of(context).pop(); }, onUpdate: () async { Navigator.of(context).pop(); await _startDownload(context, info); }, ), );强制更新showDialog( context: context, barrierDismissible: false, builder: (context) WillPopScope( onWillPop: () async false, // 拦截返回键 child: UpdateDialog( info: info, onLater: null, // 不显示“以后再说” onUpdate: () async { Navigator.of(context).pop(); await _startDownload(context, info); }, ), ), );强制更新时对话框的“以后再说”按钮直接不渲染同时用WillPopScope拦截返回键。用户只能选择更新没有其他退出路径。这里要注意如果用户不更新不要用弹窗反复轰炸而是退到页面后提示“不升级将无法使用”给用户留一个关闭弹窗的选择但应用后续仍然限制功能使用。4.3 下载更新包的实现与进度管理用户点击“立即更新”后开始下载HAP包。下载逻辑我用dio的download方法支持进度回调Futurevoid _startDownload(BuildContext context, UpdateInfo info) async { // 获取应用文档目录 final dir await getApplicationDocumentsDirectory(); final downloadDir Directory(${dir.path}/update); if (!downloadDir.existsSync()) { downloadDir.createSync(recursive: true); } final savePath ${downloadDir.path}/app_v${info.versionName}.hap; // 下载前检查磁盘空间 final freeSpace await _getFreeSpace(); if (freeSpace info.size * 1.2) { _showTip(context, 磁盘空间不足请清理后重试); return; } // 带进度回调的下载 final response await dio.download( info.downloadUrl, savePath, onReceiveProgress: (count, total) { if (total 0) return; // 兼容服务器不返回Content-Length的情况 final progress count / total; // 更新弹窗里的进度条 _updateProgress(context, progress); }, ); if (response.statusCode 200) { // 下载完成校验MD5 final valid await _verifyMd5(savePath, info.md5); if (valid) { await NativeChannel.installApk(savePath); } else { File(savePath).deleteSync(); _showTip(context, 下载文件校验失败请重试); } } }这里有三个容易被忽略的细节。一是磁盘空间检查。HAP包通常几十MB到上百MB下载前用系统接口查一下剩余空间比下载到一半报错体验好得多。二是进度回调的total参数。某些CDN服务器不返回Content-Lengthtotal会是-1如果不加判断直接除进度条会变成无限循环。上面代码里我加了if (total 0) return这样至少不会报错。三是MD5校验。下载完成必须校验文件完整性特别是通过HTTP下载的场景文件被截断或者内容损坏的概率比想象中高。MD5用crypto包计算Futurebool _verifyMd5(String filePath, String expectedMd5) async { final file File(filePath); final bytes await file.readAsBytes(); // 大文件不建议这样 final digest md5.convert(bytes).toString(); return digest.toLowerCase() expectedMd5.toLowerCase(); }官方文档式的写法是用readAsBytes但更新包动不动就上百MB一次性读进内存非常伤。实际项目里要么用流式计算MD5要么干脆把下载和校验都放到isolate里执行。Flutter的compute函数可以帮上忙或者用flutter_isolate在后台线程里做。这块既是性能问题也是内存问题——我在跑真机时遇到过200MB的包直接OOM的情况后来改成流式读取才解决。流式计算MD5的简化示例FutureString md5FromStream(String filePath) async { final file File(filePath); final stream file.openRead(); final chunks Listint[]; await for (final chunk in stream) { chunks.add(chunk); } return md5.convert(chunks.expand((c) c)).toString(); }如果包特别大chunks列表也会很长。更优的方案是用DigestSink配合IOSink流式写入这里不展开核心思路就是“别一口气读进内存”。4.4 鸿蒙侧拉起安装页面下载完成后Flutter侧无法直接触发安装必须通过MethodChannel调用鸿蒙原生能力拉起系统安装器。Flutter侧发起调用class NativeChannel { /// 拉起系统安装器 static Futurebool installApk(String filePath) async { final bool success await _channel.invokeMethod(installApp, { filePath: filePath, }); return success; } }鸿蒙原生侧用Want拉起安装页面import { common, Want } from kit.AbilityKit; import { hilog } from kit.PerformanceAnalysisKit; // 在方法通道实现里增加 installApp 分支 if (call.method installApp) { const filePath call.arguments[filePath]; let want: Want { action: ohos.want.action.VIEW_DATA, uri: file:// filePath, type: application/vnd.huawei.app-package }; this.context.startAbility(want) .then(() result.success(true)) .catch((err) { hilog.error(0x0000, UpdatePlugin, start ability error: %{public}s, JSON.stringify(err)); result.success(false); }); }这段代码的关键点是type字段必须使用application/vnd.huawei.app-package系统才能识别为鸿蒙应用包并拉起安装器。如果这个字段写错系统会把它当普通文件处理可能打开一个文本查看器来决定这个包怎么打开。另外如果系统弹出了“禁止安装未知来源应用”的提示需要在错误处理里引导用户跳转到设置页// 打开系统设置中本应用的权限管理 let settingsWant: Want { action: ohos.settings.settings, uri: application_details, parameters: { settingsParamBundleName: com.example.app } }; this.context.startAbility(settingsWant);5. 常见问题与排查技巧5.1 深色模式下颜色失效深色模式最典型的坑是开发时用亮色模式测试正常切到深色模式后弹窗依然白底白字。排查一圈发现问题出在某个地方硬编码了Color(0xFF333333)作为文字颜色这个颜色在亮色下没问题但深色模式下背景变成深灰深灰文字配深灰背景自然看不清。解决办法是全局统一颜色入口不允许组件内部直接new Color。在项目早期就要定这个规矩不然随着业务代码膨胀想改就非常痛苦。我的建议是至少整理一个AppColors工具类或者直接依赖Flutter的ColorScheme。另一个隐蔽问题是有些鸿蒙设备在深色模式下系统弹窗会叠加一层透明度变化导致自定义Dialog的背景色和预期不一致。这个我暂时没有找到完全通用的规律保险做法是弹窗的backgroundColor设置成完全不透明的颜色不要用带透明度的色值。5.2 更新包下载失败与内存问题下载失败这件事大部分情况下不是代码问题而是网络环境问题。我在实际测试中遇到过几种典型情况现象原因解决方法下载进度一直0%服务器不支持Range请求或CDN没有正确返回Content-Length服务端调整配置或改用支持断点续传的下载地址下载到一半报连接重置弱网下TCP被重置常见于4G网络切换增加重试机制断点续传失败后从上次位置继续下载进度条到100%但文件不完整CDN返回了错误页面MD5校验通过不了下载后必做MD5校验不合格删除重下大文件下载时App卡死文件读写操作在主线程下载和MD5计算都放到后台线程/isolate尤其是大文件必须把下载和校验放到后台isolate里执行。Flutter的UI线程和平台线程是有隔离的但如果直接在build方法里同步读取大文件还是会卡住界面。我实测下来100MB的包在主线程里readAsBytes页面会卡住将近2秒。放到isolate之后UI丝滑流畅下载进度照常更新。5.3 弹窗在部分鸿蒙设备上样式错乱自定义Dialog在部分鸿蒙设备上出现过尺寸溢出的问题。原因是某些设备的系统字体缩放比例很高Dialog里面的文字和按钮高度被放大后超出了预设的Column空间。解决办法有两个一是给Dialog加一层SingleChildScrollView内容超出时允许滚动二是在Dialog外层用MediaQuery限制最大宽度Dialog( insetPadding: const EdgeInsets.symmetric(horizontal: 24), child: SizedBox( width: MediaQuery.of(context).size.width - 48, child: SingleChildScrollView( child: Column(...), ), ), )限定弹窗宽度不超过屏幕宽度减48再配合滚动基本能覆盖大多数字体缩放场景。5.4 检测接口被误拦或超时更新检测接口在部分鸿蒙设备上出现超时排查发现是设备上安装了某些网络管理工具或者系统的隐私保护设置拦截了应用的后台网络请求。这类问题很难从代码层面完全规避只能做好超时和重试try { final resp await dio.post( /api/v1/app/check_update, data: {...}, ).timeout(const Duration(seconds: 8)); // 处理响应 } on DioException catch (e) { if (e.type DioExceptionType.connectionTimeout || e.type DioExceptionType.receiveTimeout) { // 静默失败不弹任何提示 return; } }更新检测接口失败了一定要静默处理。这个接口是后台任务不影响用户正常使用App。如果失败弹一个报错弹窗体验反而更差。调试阶段要抓包验证接口和下载地址是否正常我用过Charles和抓包工具配合。需要注意鸿蒙设备上安装系统证书的流程和Android有些差异如果抓不到HTTPS明文先在代码里临时把dio的证书校验关掉调试确认接口通了再改回来(dio.httpClientAdapter as IOHttpClientAdapter).onHttpClientCreate (client) { client.badCertificateCallback (cert, host, port) true; // 仅调试用 return client; };线上环境绝对不能这样配这行代码留在生产代码里等于把HTTPS证书校验完全关闭非常危险。6. 后续扩展与个人建议6.1 还可以继续做的优化更新检测功能跑通之后后续可以扩展的方向还不少。第一是静默下载。在用户点击“以后再说”之后如果当前网络是Wi-Fi可以静默把更新包下载到本地等用户再次打开App时提示“新版本已经准备就绪是否安装”。这样用户点击确认时几乎0等待安装转化率会高很多。第二是灰度发布。接口设计里已经预留了deviceId和channel字段服务端可以按设备号做白名单或者按百分比放量。新版本先给内部测试设备推确认没问题再全量铺开这个运营手段在客户端几乎不用改代码。第三是断点续传。当前实现里下载失败就重头开始如果是200MB的包在弱网下体验很差。dio原生支持http头的Range服务端配合支持的话可以在失败后从上次下载的位置继续。第四是更新包增量差量。HAP包拆分公共资源后增量包可能只有全量包的30%~50%。但鸿蒙应用包能不能做差量合并需要看系统是否支持这个目前还不是普遍方案可以关注生态进展。6.2 更新检测这类基础能力集成的通用建议我在这次集成中得到的核心体会是基础能力类的功能一定要把“降级”和“兜底”做充分。更新检测看着简单但它影响的是用户能否用上后续所有新功能一旦这个环节出问题连修复自身的能力都会失去。具体到项目里有这么几条原则值得记住一是所有远端配置都要有本地兜底。接口返回的结构可能不完整字段可能缺失解析时要用默认值兜底不要因为一个字段解析失败导致整个功能崩溃。二是交互上要给用户“逃逸”路径。除了强制更新外普通更新一定允许用户暂时忽略。反复弹窗的后果就是用户卸载这个度要掌握好。三是版本比较逻辑必须优先稳定。相比复杂的UI交互版本判断出错才是灾难级的——该更新的不更新不该更新的瞎更新。数字比较永远是可靠的字符串比较不靠谱。四是监控要做好。更新接口的成功率、失败次数、异常信息尽量在实施时埋点。基础功能本身没有存在感但它的稳定性直接影响全局。出了问题能第一时间察觉比什么都强。最后再分享一个小技巧更新包下载地址的域名最好和服务端接口的域名分开或者使用独立的CDN。这样就算接口服务出现故障已经下发到设备的下载任务还能正常完成。我在上线第一天就遇到过一次接口服务重启导致下载请求被拒还好当时包已经缓存到本地用户没感知。这种事碰上一次就知道基础功能多留一条路永远不会亏。
返回列表