做当前App应用内更新的前提:用户可以在App更新的时候还可以使用软件,不能让他一直等待下载,关闭下载组件后,打开组件还能继续看到他的进度,并且用户锁屏或者短暂退出APP后需要保证任务不能总是挂掉,这里没有用到保活,而是需要用到生命周期去让他暂停和恢复,所以当前app还要有一个全局管理类保存任务id。UI图在最后
(插件文档说明可以通知栏显示,但是我用的这个版本在100%下完后总是failed,而不是complete,就不会很友好,后面我就删掉了,不会详细介绍,大体就是插件文档中配置文件配置后,还需要去授权通知栏权限)
为什么选择flutter_downloader而非ota_update插件
一开始我是选择后者ota的,这个插件用起来十分简单,只需要执行一个下载api就能获取进度和下载状态,但是有一点不好的就是从1%到2%会监听很多次,也就是print打印后会出现十几次1%的打印,这时候我是每4格就setState一次。
坑点:但是他的文档写到他的下载是保存在内部存储空间的,当时我去调用了好几个类似getApplicationDocumentsDirectory的api都无法访问到这个目录中下载的apk,并且没有任务id这种概念的,不能暂停和开始,也不存在这种api,只是单纯的一股劲下载完,所以退出路由后,你就完全不知道他的任务id并继续了,十分适合那种强制升级的UI界面;
因为我当前产品UI逻辑的问题,这个插件就无法做到下载完毕后用户继续安装,导致需要重复下载,对用户不友好,要我是用户,我直接发飙破口大骂了,所以后面看了几个帖子发现了flutter_downloader插件还不错的样子有1k多的点赞
flutter_downloader插件特点:每11%会监听一次进度,并且每一次暂停任务后再启动任务就会生成一个新的任务id这个比较重点,文档写了,并且有暂停,开始,根据id查询任务对象,比较全面,符合我的需求,并且做完后我也有个疑问点,在后台太久了,任务会被cancled,这种情况是无法被恢复的,这时候我新启动一个任务,他居然继承了上个任务的进度!!!比如上个任务是59退到后台然后被杀死了,这时候我逻辑进来判断cancled的话就删除这个任务开启下个任务进度就会是59!这我在文档没看到有相关介绍,难道是同名文件他会被继承进度???有懂的吗?
tip:这个插件在模拟器上好像没效果,我都是wifi真机测试的
有一些xml的文件,直接跟着官方文档来就行了:https://pub.dev/packages/flutter_downloader#android-integration
并且xml里面获取文件管理权限和安装权限还需要以下两者
1 `<!--请求下载软件包权限-->
2 <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
3 <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
4 tools:ignore="ScopedStorage" />`
5
6* 1
7* 2
8* 3
9* 4
10
Flutter相关插件:
1package_info_plus: ^8.0.2
2 url_launcher: ^6.3.0
3 flutter_downloader: ^1.11.8
4 install_plugin: ^2.1.0
5 crypto: ^3.0.5
UI相关的代码我就不会贴出来了
实现逻辑:package_info_plus这个插件可以用下面的fromPlatform获取当前包信息,然后我们的当前发布版本信息是保存在服务器上的json文件,我是通过访问这个json文件获取发布版本信息的,一般包含版本号,构建号,apk的哈希值(判断apk包完整性需要用到,一般打完包的同级目录下会有一个sha1后缀的文件,里面就是你这个apk包的哈希值,当然也可以去一些网站里面也可以帮你算出来这个哈希值)
1Future<PackageInfo> getVersion() async {
2 PackageInfo packageInfo = await PackageInfo.fromPlatform();
3 return packageInfo;
4 }
当前包信息包含这些
1PackageInfo packageInfo = PackageInfo(appName: '', packageName: '', version: '', buildNumber: '');
下载所需的代码
我用的这个是flutter的3.2.3版本以后的生命周期新写法,比较直观的看出它存在哪些api吧,这里需要用到的是暂停和恢复。其实这里应该后面说的,因为要从下载那块说起。
1@override
2 void initState() {
3
4 appLifecycleListener = AppLifecycleListener(
5 onStateChange: (AppLifecycleState state) {},
6 onResume: onResume,
7 onInactive: () {},
8 onHide: () {},
9 onShow: () {},
10 onPause: onPause,
11 onRestart: () {},
12 onDetach: () {},
13 );
14
15
16 fetchDownloadStatus();
17 super.initState();
18 }
19
20
21 onPause() {
22 if (SharedPreferencesUtil.getDownloadTaskId() != '') {
23 FlutterDownloader.pause(taskId: SharedPreferencesUtil.getDownloadTaskId());
24 }
25 }
26
27
28 onResume() async {
29 if (SharedPreferencesUtil.getDownloadTaskId() != '') {
30
31 String? taskId = await FlutterDownloader.resume(taskId: SharedPreferencesUtil.getDownloadTaskId());
32 SharedPreferencesUtil.setDownloadTaskId(taskId!);
33 }
34 }
偷个懒把所有代码贴出来算了,一步一步讲解太麻烦了,这个代码里面其实每行我都写了注释,
步骤:
1:授权他的文件管理权限Permission.manageExternalStorage.request();否则触发不了安装效果,文档是这么说的要外部存储空间的权限
2:启动下载任务,然后将返回的id存到全局中
3:初始化的时候已经启动了监听,registerDownloadTask方法就会监听到任务已经启动,此时这个监听的数据是从他的回调函数downloadCallback里面被send过来的,大体来说按照文档来的,有不会的可以参考我的写法
4:最后下载完之后我没有判断apk的完整性,因为如果不完整,在安装的时候系统自身就会提醒我们,我就没做了,因为会稍微有点卡顿
5:如果退到后台不暂停任务,就会导致任务canceled,这种状态不允许resume这个任务的,需要重新开始从0下载任务,如果想要他不被cancled,需要用到生命周期监听应用的状态去暂停和恢复,这样子就不会导致任务cancled,但是太久了也会让他挂掉的,这种暂停的方式还是挺简单的,并且如果组件被销毁了,那些端口一定要摧毁掉,不然会存在问题的。保活的话得知道原生咋做的,这个后面我得研究研究
6:下载完后用户不小心点击取消安装了,或者下载到一半退出App了,这时候外部存储空间都会留下一个apk了,所以这个时候哈希值的作用就出来了,专门判断apk是否完整的,如果是完整的,就直接安装它,如果不完整,我采取的逻辑是删除掉这个apk,并且让用户重新下载,我参考了很多主流App除了应用商店都是不会有继续下载的这种动作的,大部分都是后台静默下载或者强制下载完后打开,如果下载到一半退出了就重新下载。有不会的地方可以评论区问出来。
1import 'dart:async';
2import 'dart:io';
3import 'dart:isolate';
4import 'dart:ui';
5
6import 'package:crypto/crypto.dart';
7import 'package:flutter/material.dart';
8import 'package:flutter_downloader/flutter_downloader.dart';
9import 'package:gl_business_platform_flutter/components/custom_bottom_sheet.dart';
10import 'package:gl_business_platform_flutter/components/custom_button.dart';
11import 'package:gl_business_platform_flutter/managers/settings_manager.dart';
12import 'package:gl_business_platform_flutter/utils/notify_util.dart';
13import 'package:gl_business_platform_flutter/utils/shared_preferences_util.dart';
14import 'package:install_plugin/install_plugin.dart';
15import 'package:permission_handler/permission_handler.dart';
16import 'package:url_launcher/url_launcher.dart';
17import 'package:path_provider/path_provider.dart';
18import '../../../styles/custom_style.dart';
19import '../../../utils/request/request.dart';
20import '../util/setting_util.dart';
21
22class DownloadSheet extends StatefulWidget {
23
24
25
26 final Map<String, dynamic> packageInfo;
27
28 final SettingsManager settingsManager;
29
30 final bool isIosSystem;
31
32 final String appId;
33
34 const DownloadSheet(
35 {super.key,
36 required this.packageInfo,
37 required this.settingsManager,
38 required this.isIosSystem,
39 required this.appId});
40
41 @override
42 State<DownloadSheet> createState() => _DownloadSheetState();
43}
44
45class _DownloadSheetState extends State<DownloadSheet> {
46 var progress = 0;
47
48
49 var progressStatus = ProgressType.examineStatus;
50
51 final ReceivePort _port = ReceivePort();
52
53 late final AppLifecycleListener appLifecycleListener;
54
55 @override
56 void initState() {
57
58 appLifecycleListener = AppLifecycleListener(
59 onStateChange: (AppLifecycleState state) {},
60 onResume: onResume,
61 onInactive: () {},
62 onHide: () {},
63 onShow: () {},
64 onPause: onPause,
65 onRestart: () {},
66 onDetach: () {},
67 );
68
69
70 fetchDownloadStatus();
71 super.initState();
72 }
73
74
75 onPause() {
76 if (SharedPreferencesUtil.getDownloadTaskId() != '') {
77 FlutterDownloader.pause(taskId: SharedPreferencesUtil.getDownloadTaskId());
78 }
79 }
80
81
82 onResume() async {
83 if (SharedPreferencesUtil.getDownloadTaskId() != '') {
84
85 String? taskId = await FlutterDownloader.resume(taskId: SharedPreferencesUtil.getDownloadTaskId());
86 SharedPreferencesUtil.setDownloadTaskId(taskId!);
87 }
88 }
89
90
91 @pragma('vm:entry-point')
92 static void downloadCallback(String id, int status, int progress) {
93
94 final SendPort? send = IsolateNameServer.lookupPortByName('downloader_send_port');
95 send?.send([id, status, progress]);
96 }
97
98 @override
99 void dispose() {
100
101 _port.close();
102
103
104 IsolateNameServer.removePortNameMapping('downloader_send_port');
105
106
107 appLifecycleListener.dispose();
108 super.dispose();
109 }
110
111
112 Future<void> fetchDownloadStatus() async {
113 String taskId = SharedPreferencesUtil.getDownloadTaskId();
114 List<DownloadTask>? taskInfo;
115 if (taskId != '') {
116
117 taskInfo = await FlutterDownloader.loadTasksWithRawQuery(query: "SELECT * FROM task WHERE task_id='$taskId'");
118 }
119 if (taskInfo != null && taskInfo.isNotEmpty && taskInfo[0].status.index == 2) {
120
121 final downLoadProgress = taskInfo[0].progress;
122 setState(() {
123 progressStatus = ProgressType.downloadStatus;
124 progress = downLoadProgress;
125 });
126 } else {
127
128 SharedPreferencesUtil.setDownloadTaskId('');
129 installValidation(false);
130 }
131 registerDownloadTask();
132 }
133
134
135 void registerDownloadTask() {
136
137 IsolateNameServer.registerPortWithName(_port.sendPort, 'downloader_send_port');
138
139
140 FlutterDownloader.registerCallback(downloadCallback);
141
142
143 _port.listen((dynamic data) async {
144
145
146 DownloadTaskStatus status = DownloadTaskStatus.values[data[1]];
147
148
149 int downLoadProgress = data[2];
150 setState(() {
151 if (status == DownloadTaskStatus.running) {
152 progressStatus = ProgressType.downloadStatus;
153 progress = downLoadProgress;
154 } else if (status == DownloadTaskStatus.canceled) {
155 cleanDownloadStatus("下载被中断");
156 } else if (status == DownloadTaskStatus.failed && progress != 100) {
157 cleanDownloadStatus("下载过于频繁,请稍等");
158 }
159 });
160
161
162 if (downLoadProgress.toString() == "100" && status == DownloadTaskStatus.running) {
163 installValidation(true);
164 }
165 });
166 }
167
168
169 void cleanDownloadStatus(String title) {
170 progressStatus = ProgressType.prepareStatus;
171 SharedPreferencesUtil.setDownloadTaskId('');
172 progress = 0;
173 NotifyUtil.showToast(title);
174 }
175
176
177
178 Future<void> installValidation(bool isDownloadComplete) async {
179
180 final Directory externalDir = await getApplicationDocumentsDirectory();
181 String apkPath = "${externalDir.path}/${widget.settingsManager.apkName}";
182
183
184 if (isDownloadComplete) {
185
186 await installEvent(apkPath);
187 setState(() {
188 progressStatus = ProgressType.installStatus;
189 });
190 return;
191 }
192 File file = File(apkPath);
193
194
195 if (!file.existsSync()) {
196 setState(() {
197 progressStatus = ProgressType.prepareStatus;
198 });
199 return;
200 }
201
202
203 Timer(const Duration(milliseconds: 100), () => judgeApk(file));
204 }
205
206
207 void judgeApk(File file) {
208 computeSha1OfFile(file).then((sha1) {
209
210 String publishApkSha1 = widget.packageInfo['apk_sha1'];
211 if (Request.getBaseUrl == "你的请求路径,这里是用来区分正式环境和测试环境的,看你自己") {
212 publishApkSha1 = widget.packageInfo['apk_test_sha1'];
213 }
214 if (sha1 == publishApkSha1) {
215
216
217 setState(() {
218 progressStatus = ProgressType.installStatus;
219 });
220 } else {
221
222
223 file.delete();
224 setState(() {
225 progressStatus = ProgressType.prepareStatus;
226 });
227 }
228 });
229 }
230
231 Future<void> installEvent(String apkPath) async {
232 final res = await InstallPlugin.install(apkPath);
233 if (res['isSuccess']) {
234 NotifyUtil.showToast("安装成功");
235 } else if (res['errorMessage'] == "Install Cancel") {
236 NotifyUtil.showToast("您取消了安装");
237 }
238 }
239
240
241 Future<String> computeSha1OfFile(File file) async {
242 final bytes = await file.readAsBytes();
243 final digest = sha1.convert(bytes);
244 return digest.toString();
245 }
246
247
248 void androidDownload() async {
249 var status = await Permission.manageExternalStorage.request();
250 if (status == PermissionStatus.denied) {
251 NotifyUtil.showToast('存储权限已被拒绝,请在设置中开启存储权限。');
252 return;
253 } else if (status == PermissionStatus.permanentlyDenied) {
254 NotifyUtil.showToast('存储权限被永久拒绝,请在设置中开启存储权限。');
255 return;
256 }
257 final Directory externalDir = await getApplicationDocumentsDirectory();
258
259
260 if (progressStatus == ProgressType.installStatus) {
261 String apkPath = "${externalDir.path}/${widget.settingsManager.apkName}";
262 installEvent(apkPath);
263 } else if (progressStatus == ProgressType.prepareStatus && SharedPreferencesUtil.getDownloadTaskId() == '') {
264 setState(() {
265 progressStatus = ProgressType.downloadStatus;
266 });
267
268
269 final taskId = await FlutterDownloader.enqueue(
270 url: widget.settingsManager.androidDownloadUrl,
271 headers: {},
272 savedDir: externalDir.path,
273 fileName: widget.settingsManager.apkName,
274 showNotification: true,
275 openFileFromNotification: true,
276 );
277 SharedPreferencesUtil.setDownloadTaskId(taskId.toString());
278 }
279 }
280
281
282 void iosDownload() async {
283 final appStoreUrl = Uri.parse('https://apps.apple.com/app/id${widget.appId}');
284 if (await canLaunchUrl(appStoreUrl)) {
285 await launchUrl(appStoreUrl);
286 } else {
287 NotifyUtil.showToast("AppStore打开应用失败。");
288 }
289 }
290
291
292 Widget actionButtons(BuildContext context) {
293 final buttonWidth = (MediaQuery.of(context).size.width - 48);
294 String buttonTitle = progressStatus.description;
295 if (progressStatus == ProgressType.downloadStatus) {
296 buttonTitle = "${progressStatus.description}: $progress%";
297 }
298 return Row(
299 mainAxisAlignment: MainAxisAlignment.center,
300 children: [
301 CustomButton(
302 width: buttonWidth,
303 buttonText: widget.isIosSystem ? "前往AppStore更新" : buttonTitle,
304 onPressed: () {
305 widget.isIosSystem ? iosDownload() : androidDownload();
306 })
307 ],
308 );
309 }
310
311 @override
312 Widget build(BuildContext context) {
313 return CustomBottomSheet(
314 backgroundColor: currentStyle.workBenchCardColor,
315 height: 400,
316 title: '版本更新',
317 contentWidget: SingleChildScrollView(
318 child: Container(
319 color: currentStyle.workBenchCardColor,
320 width: MediaQuery.of(context).size.width,
321 padding: const EdgeInsets.symmetric(horizontal: 16),
322 child: Column(
323 children: [
324 Image.asset('assets/images/update-version.png'),
325 const SizedBox(height: 10),
326 Text("发现新版本:", style: currentStyle.searchBarInputText),
327 Text(widget.packageInfo['name'] + ' ' + widget.packageInfo['version'],
328 style: currentStyle.searchBarInputText),
329 Padding(
330 padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 34),
331 child: Row(
332 children: [
333 Expanded(
334 child: Text(
335 "主要更新\n 修复了一些已知问题",
336 style: currentStyle.customerTypeText,
337 )),
338 ],
339 )),
340 ],
341 ),
342 ),
343 ),
344 buttonWidget: actionButtons(context),
345 );
346 }
347}
这个放在另外的dart文件中,用于下载状态栏文字的枚举的
1enum ProgressType {
2 examineStatus(0, "检查本地安装包..."),
3 prepareStatus(1, "立即更新"),
4 downloadStatus(2, "下载中"),
5 installStatus(3, "继续安装");
6
7 final int code;
8 final String description;
9
10 const ProgressType(this.code, this.description);
11}
在这里插入图片描述