首页/文章/flutter

Flutter应用内更新App版本使用flutter_downloader而非ota_update

2024-11-03
22658 分钟
...

做当前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里面获取文件管理权限和安装权限还需要以下两者

 `<!--请求下载软件包权限-->
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
        tools:ignore="ScopedStorage" />` 

*   1
*   2
*   3
*   4

Flutter相关插件:

package_info_plus: ^8.0.2 
  url_launcher: ^6.3.0
  flutter_downloader: ^1.11.8
  install_plugin: ^2.1.0
  crypto: ^3.0.5

UI相关的代码我就不会贴出来了
实现逻辑:package_info_plus这个插件可以用下面的fromPlatform获取当前包信息,然后我们的当前发布版本信息是保存在服务器上的json文件,我是通过访问这个json文件获取发布版本信息的,一般包含版本号,构建号,apk的哈希值(判断apk包完整性需要用到,一般打完包的同级目录下会有一个sha1后缀的文件,里面就是你这个apk包的哈希值,当然也可以去一些网站里面也可以帮你算出来这个哈希值)

Future<PackageInfo> getVersion() async {
    PackageInfo packageInfo = await PackageInfo.fromPlatform();
    return packageInfo;
  }

当前包信息包含这些

PackageInfo packageInfo = PackageInfo(appName: '', packageName: '', version: '', buildNumber: '');

下载所需的代码

我用的这个是flutter的3.2.3版本以后的生命周期新写法,比较直观的看出它存在哪些api吧,这里需要用到的是暂停和恢复。其实这里应该后面说的,因为要从下载那块说起。

@override
  void initState() {
    
    appLifecycleListener = AppLifecycleListener(
      onStateChange: (AppLifecycleState state) {},
      onResume: onResume,
      onInactive: () {},
      onHide: () {},
      onShow: () {},
      onPause: onPause,
      onRestart: () {},
      onDetach: () {},
    );

    
    fetchDownloadStatus();
    super.initState();
  }

  
  onPause() {
    if (SharedPreferencesUtil.getDownloadTaskId() != '') {
      FlutterDownloader.pause(taskId: SharedPreferencesUtil.getDownloadTaskId());
    }
  }

  
  onResume() async {
    if (SharedPreferencesUtil.getDownloadTaskId() != '') {
      
      String? taskId = await FlutterDownloader.resume(taskId: SharedPreferencesUtil.getDownloadTaskId());
      SharedPreferencesUtil.setDownloadTaskId(taskId!);
    }
  }

偷个懒把所有代码贴出来算了,一步一步讲解太麻烦了,这个代码里面其实每行我都写了注释,
步骤:
1:授权他的文件管理权限Permission.manageExternalStorage.request();否则触发不了安装效果,文档是这么说的要外部存储空间的权限
2:启动下载任务,然后将返回的id存到全局中
3:初始化的时候已经启动了监听,registerDownloadTask方法就会监听到任务已经启动,此时这个监听的数据是从他的回调函数downloadCallback里面被send过来的,大体来说按照文档来的,有不会的可以参考我的写法
4:最后下载完之后我没有判断apk的完整性,因为如果不完整,在安装的时候系统自身就会提醒我们,我就没做了,因为会稍微有点卡顿
5:如果退到后台不暂停任务,就会导致任务canceled,这种状态不允许resume这个任务的,需要重新开始从0下载任务,如果想要他不被cancled,需要用到生命周期监听应用的状态去暂停和恢复,这样子就不会导致任务cancled,但是太久了也会让他挂掉的,这种暂停的方式还是挺简单的,并且如果组件被销毁了,那些端口一定要摧毁掉,不然会存在问题的。保活的话得知道原生咋做的,这个后面我得研究研究
6:下载完后用户不小心点击取消安装了,或者下载到一半退出App了,这时候外部存储空间都会留下一个apk了,所以这个时候哈希值的作用就出来了,专门判断apk是否完整的,如果是完整的,就直接安装它,如果不完整,我采取的逻辑是删除掉这个apk,并且让用户重新下载,我参考了很多主流App除了应用商店都是不会有继续下载的这种动作的,大部分都是后台静默下载或者强制下载完后打开,如果下载到一半退出了就重新下载。有不会的地方可以评论区问出来。

import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';

import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:gl_business_platform_flutter/components/custom_bottom_sheet.dart';
import 'package:gl_business_platform_flutter/components/custom_button.dart';
import 'package:gl_business_platform_flutter/managers/settings_manager.dart';
import 'package:gl_business_platform_flutter/utils/notify_util.dart';
import 'package:gl_business_platform_flutter/utils/shared_preferences_util.dart';
import 'package:install_plugin/install_plugin.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:path_provider/path_provider.dart';
import '../../../styles/custom_style.dart';
import '../../../utils/request/request.dart';
import '../util/setting_util.dart';

class DownloadSheet extends StatefulWidget {
  
  
  
  final Map<String, dynamic> packageInfo;

  final SettingsManager settingsManager;

  final bool isIosSystem;

  final String appId;

  const DownloadSheet(
      {super.key,
      required this.packageInfo,
      required this.settingsManager,
      required this.isIosSystem,
      required this.appId});

  @override
  State<DownloadSheet> createState() => _DownloadSheetState();
}

class _DownloadSheetState extends State<DownloadSheet> {
  var progress = 0;

  
  var progressStatus = ProgressType.examineStatus;

  final ReceivePort _port = ReceivePort();

  late final AppLifecycleListener appLifecycleListener;

  @override
  void initState() {
    
    appLifecycleListener = AppLifecycleListener(
      onStateChange: (AppLifecycleState state) {},
      onResume: onResume,
      onInactive: () {},
      onHide: () {},
      onShow: () {},
      onPause: onPause,
      onRestart: () {},
      onDetach: () {},
    );

    
    fetchDownloadStatus();
    super.initState();
  }

  
  onPause() {
    if (SharedPreferencesUtil.getDownloadTaskId() != '') {
      FlutterDownloader.pause(taskId: SharedPreferencesUtil.getDownloadTaskId());
    }
  }

  
  onResume() async {
    if (SharedPreferencesUtil.getDownloadTaskId() != '') {
      
      String? taskId = await FlutterDownloader.resume(taskId: SharedPreferencesUtil.getDownloadTaskId());
      SharedPreferencesUtil.setDownloadTaskId(taskId!);
    }
  }

  
  @pragma('vm:entry-point')
  static void downloadCallback(String id, int status, int progress) {
    
    final SendPort? send = IsolateNameServer.lookupPortByName('downloader_send_port');
    send?.send([id, status, progress]);
  }

  @override
  void dispose() {
    
    _port.close();

    
    IsolateNameServer.removePortNameMapping('downloader_send_port');

    
    appLifecycleListener.dispose();
    super.dispose();
  }

  
  Future<void> fetchDownloadStatus() async {
    String taskId = SharedPreferencesUtil.getDownloadTaskId();
    List<DownloadTask>? taskInfo;
    if (taskId != '') {
      
      taskInfo = await FlutterDownloader.loadTasksWithRawQuery(query: "SELECT * FROM task WHERE task_id='$taskId'");
    }
    if (taskInfo != null && taskInfo.isNotEmpty && taskInfo[0].status.index == 2) {
      
      final downLoadProgress = taskInfo[0].progress; 
      setState(() {
        progressStatus = ProgressType.downloadStatus;
        progress = downLoadProgress;
      });
    } else {
      
      SharedPreferencesUtil.setDownloadTaskId('');
      installValidation(false);
    }
    registerDownloadTask();
  }

  
  void registerDownloadTask() {
    
    IsolateNameServer.registerPortWithName(_port.sendPort, 'downloader_send_port');

    
    FlutterDownloader.registerCallback(downloadCallback);

    
    _port.listen((dynamic data) async {
      
      
      DownloadTaskStatus status = DownloadTaskStatus.values[data[1]];

      
      int downLoadProgress = data[2];
      setState(() {
        if (status == DownloadTaskStatus.running) {
          progressStatus = ProgressType.downloadStatus;
          progress = downLoadProgress;
        } else if (status == DownloadTaskStatus.canceled) {
          cleanDownloadStatus("下载被中断");
        } else if (status == DownloadTaskStatus.failed && progress != 100) {
          cleanDownloadStatus("下载过于频繁,请稍等");
        }
      });

      
      if (downLoadProgress.toString() == "100" && status == DownloadTaskStatus.running) {
        installValidation(true);
      }
    });
  }

  
  void cleanDownloadStatus(String title) {
    progressStatus = ProgressType.prepareStatus;
    SharedPreferencesUtil.setDownloadTaskId('');
    progress = 0;
    NotifyUtil.showToast(title);
  }

  
  
  Future<void> installValidation(bool isDownloadComplete) async {
    
    final Directory externalDir = await getApplicationDocumentsDirectory();
    String apkPath = "${externalDir.path}/${widget.settingsManager.apkName}";

    
    if (isDownloadComplete) {
      
      await installEvent(apkPath);
      setState(() {
        progressStatus = ProgressType.installStatus;
      });
      return;
    }
    File file = File(apkPath);

    
    if (!file.existsSync()) {
      setState(() {
        progressStatus = ProgressType.prepareStatus;
      });
      return;
    }

    
    Timer(const Duration(milliseconds: 100), () => judgeApk(file));
  }

  
  void judgeApk(File file) {
    computeSha1OfFile(file).then((sha1) {
      
      String publishApkSha1 = widget.packageInfo['apk_sha1'];
      if (Request.getBaseUrl == "你的请求路径,这里是用来区分正式环境和测试环境的,看你自己") {
        publishApkSha1 = widget.packageInfo['apk_test_sha1'];
      }
      if (sha1 == publishApkSha1) {
        
        
        setState(() {
          progressStatus = ProgressType.installStatus;
        });
      } else {
        
        
        file.delete();
        setState(() {
          progressStatus = ProgressType.prepareStatus;
        });
      }
    });
  }

  Future<void> installEvent(String apkPath) async {
    final res = await InstallPlugin.install(apkPath);
    if (res['isSuccess']) {
      NotifyUtil.showToast("安装成功");
    } else if (res['errorMessage'] == "Install Cancel") {
      NotifyUtil.showToast("您取消了安装");
    }
  }

  
  Future<String> computeSha1OfFile(File file) async {
    final bytes = await file.readAsBytes();
    final digest = sha1.convert(bytes);
    return digest.toString();
  }

  
  void androidDownload() async {
    var status = await Permission.manageExternalStorage.request();
    if (status == PermissionStatus.denied) {
      NotifyUtil.showToast('存储权限已被拒绝,请在设置中开启存储权限。');
      return;
    } else if (status == PermissionStatus.permanentlyDenied) {
      NotifyUtil.showToast('存储权限被永久拒绝,请在设置中开启存储权限。');
      return;
    }
    final Directory externalDir = await getApplicationDocumentsDirectory();

    
    if (progressStatus == ProgressType.installStatus) {
      String apkPath = "${externalDir.path}/${widget.settingsManager.apkName}";
      installEvent(apkPath);
    } else if (progressStatus == ProgressType.prepareStatus && SharedPreferencesUtil.getDownloadTaskId() == '') {
      setState(() {
        progressStatus = ProgressType.downloadStatus;
      });

      
      final taskId = await FlutterDownloader.enqueue(
        url: widget.settingsManager.androidDownloadUrl,
        headers: {},
        savedDir: externalDir.path,
        fileName: widget.settingsManager.apkName,
        showNotification: true,
        openFileFromNotification: true,
      );
      SharedPreferencesUtil.setDownloadTaskId(taskId.toString());
    }
  }

  
  void iosDownload() async {
    final appStoreUrl = Uri.parse('https://apps.apple.com/app/id${widget.appId}');
    if (await canLaunchUrl(appStoreUrl)) {
      await launchUrl(appStoreUrl);
    } else {
      NotifyUtil.showToast("AppStore打开应用失败。");
    }
  }

  
  Widget actionButtons(BuildContext context) {
    final buttonWidth = (MediaQuery.of(context).size.width - 48);
    String buttonTitle = progressStatus.description;
    if (progressStatus == ProgressType.downloadStatus) {
      buttonTitle = "${progressStatus.description}: $progress%";
    }
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        CustomButton(
            width: buttonWidth,
            buttonText: widget.isIosSystem ? "前往AppStore更新" : buttonTitle,
            onPressed: () {
              widget.isIosSystem ? iosDownload() : androidDownload();
            })
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return CustomBottomSheet(
      backgroundColor: currentStyle.workBenchCardColor,
      height: 400,
      title: '版本更新',
      contentWidget: SingleChildScrollView(
        child: Container(
          color: currentStyle.workBenchCardColor,
          width: MediaQuery.of(context).size.width,
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Column(
            children: [
              Image.asset('assets/images/update-version.png'),
              const SizedBox(height: 10),
              Text("发现新版本:", style: currentStyle.searchBarInputText),
              Text(widget.packageInfo['name'] + ' ' + widget.packageInfo['version'],
                  style: currentStyle.searchBarInputText),
              Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 34),
                  child: Row(
                    children: [
                      Expanded(
                          child: Text(
                        "主要更新\n    修复了一些已知问题",
                        style: currentStyle.customerTypeText,
                      )),
                    ],
                  )),
            ],
          ),
        ),
      ),
      buttonWidget: actionButtons(context),
    );
  }
}

这个放在另外的dart文件中,用于下载状态栏文字的枚举的

enum ProgressType {
  examineStatus(0, "检查本地安装包..."),
  prepareStatus(1, "立即更新"),
  downloadStatus(2, "下载中"),
  installStatus(3, "继续安装");

  final int code;
  final String description;

  const ProgressType(this.code, this.description);
}

在这里插入图片描述

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
flutter2026-07-01
Dart 入门回顾
1\. Dart 是什么 Dart 是一门类型安全、支持面向对象、支持异步编程的语言。它的语法和 JavaScript、TypeScript、Java、C# 都有一些相似之处。 Dart 常见使用场景: Flutter 跨平台应用开发 命令行工具 Web 应用 服务端接口或脚本 作为学习现代类型语言的入口 Dart 的几个核心特点: 静态类型:变量、函数返回值、参数都有类型。 类型推断:很多时候可以用 ,让 Dart 自动推断类型。 空...
学习
flutter2026-07-01
Flutter 常用 Widget 入门
Flutter 的核心思想是:一切皆 Widget。文字、图片、按钮、页面结构、布局、间距、滚动、动画、手势,几乎都由 Widget 组合出来。 1\. Widget 先分成几类理解 | 类别 | 常用 Widget | 解决什么问题 | | | | | | 应用入口 | 、 | 搭建应用外壳、主题、路由 | | 页面骨架 | 、 、 | 标准页面结构 | | 基础显示 | 、 、 | 显示文字、图标、图片 | | 布局容器 | 、 、...
学习
flutter2024-11-21
在 Flutter 中使用 webview_flutter 4.0
1. 2. ,已入选 3. 4. 本文是第 4 篇,定位是新手入门,介绍和演示 webview 的基础用法。最后还介绍了事件处理的技巧。因为是补写的,所以本来应该首发的却发在最后了。 环境准备已经在 说过了,不再赘述。既然是新手入门,就会本着详尽的原则,而且会多配示例。 获取页面信息 获取页面 title 核心方法:controller.getTitle 完整示例,放在 main.dart 就能运行。运行示例,点击获取 title 的按...
学习
flutter2024-11-06
flutter android端运行报错Namespace not specified. Specify a namespace in the module‘s build file
在使用flutterSDK 3.24之后发现新建项目,项目默认都是基于AGP8了,由于 AGP 开始强制要求 namespace 配置,不仅仅是主工程,也包括第三方库都强制要求 namespace 。但是我们在引入第三方库的时候会发现部分库没能及时的去做相关配置,这也就导致了我们会出现标题错误: 出现这个问题,我们可能会通过去找到本地缓存的第三方库代码去修改它的build.gradle和AndroidManifest.xml来解决缺失...
学习
flutter2024-11-03
Flutter中如何实现APP升级功能背景
背景 每个App应用中一定会包含的功能 ,App的运行平台不同实现升级功能的方式也就不同,基本分类如下所示。本文主要聚焦在使用Flutter框架开发的应用在安卓系统内如何更新,以及所涉及要点梳理。 IOS系统 Andriod系统 官网下载 各类应用市场 (本文聚焦点) 功能清单(按步骤) UpgradeCard组件(展示升级内容) 进行新旧版本对比判断是否需要更新 下载更新然后安装应用 UpgradeCard组件 组件需要包含基本标题、...
学习
flutter2024-11-03
强大的Flutter App升级功能
注意:无特殊说明,Flutter版本及Dart版本如下: Flutter版本: 1.12.13+hotfix.5 Dart版本: 2.7.0 应用程序升级功能是App的基础功能之一,如果没有此功能会造成用户无法升级,应用程序的bug或者新功能老用户无法触达,甚至损失这部分用户。 对于应用程序升级功能的重要性就无需赘言了,下面介绍下应用程序升级功能的几种方式,从平台方面来说: IOS平台,应用程序升级功能只能通过跳转到app store进...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录