Flutter中如何实现APP升级功能

4,428 阅读4分钟

背景

每个App应用中一定会包含的功能升级,App的运行平台不同实现升级功能的方式也就不同,基本分类如下所示。本文主要聚焦在使用Flutter框架开发的应用在安卓系统内如何更新,以及所涉及要点梳理。

  • IOS系统
  • Andriod系统
    • 官网下载
    • 各类应用市场
    • App内更新(本文聚焦点)

功能清单(按步骤)

  • UpgradeCard组件(展示升级内容)
  • 进行新旧版本对比判断是否需要更新
  • 下载更新然后安装应用

UpgradeCard组件

组件需要包含基本标题、弹框内容、确认按钮、取消按钮以及最为关键的进度条显示。不过这个组件还不具备下载安装功能。

UpgradeCard组件代码

//upgrade_card.dart文件
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

class UpgradeCard extends StatefulWidget {
  ///标题
  String title;
  ///更新内容
  String message;
  ///确认按钮
  String positiveBtn;
  ///取消按钮
  String negativeBtn;
  ///确定按钮回调
  final GestureTapCallback positiveCallback;
  ///取消按钮回调
  final GestureTapCallback negativeCallback;
  ///条形下载进度条,默认不展示
  bool hasLinearProgress;

  UpgradeCard(
      {this.title = "",
      this.message = "",
      this.positiveBtn = "",
      this.negativeBtn = "",
      required this.positiveCallback,
      required this.negativeCallback,
      this.hasLinearProgress = false});

  final _upgradeCardState = _UpgradeCardState();

  @override
  _UpgradeCardState createState() => _upgradeCardState;

  /// 外部更新函数
  void updateProgress(String title, String message, String positiveBtn,
          String negativeBtn, bool hasLinearProgress, double progress) =>
      _upgradeCardState.updateProgress(title, message, positiveBtn,
          negativeBtn, hasLinearProgress, progress);
}

class _UpgradeCardState extends State<UpgradeCard> {
  /// 进度条数值
  double progress = 0;

  /// 内部更新函数
  void updateProgress(String title, String message, String positiveBtn,
      String negativeBtn, bool hasLinearProgress, double progress) {
    setState(() {
      widget.title = title;
      widget.message = message;
      widget.positiveBtn = positiveBtn;
      widget.negativeBtn = negativeBtn;
      widget.hasLinearProgress = hasLinearProgress;
      progress = progress;
    });
  }

  @override
  Widget build(BuildContext context) {
    var messageStyle = TextStyle(
        fontSize: 14,
        fontWeight: FontWeight.w400,
        decoration: TextDecoration.none,
        color: Color(0xFF333130),
        height: 1.6);

    return Center(
      child: Container(
        width: 320,
        padding: EdgeInsets.only(bottom: 17),
        decoration: BoxDecoration(
            color: Colors.white, borderRadius: BorderRadius.circular(8)),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            //背景图片
            Container(
              padding: EdgeInsets.only(bottom: 8),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.asset(
                  "assets/images/updateBg.png",
                  width: 320,
                ),
              ),
            ),

            ///标题
            Visibility(
              visible: widget.title.isNotEmpty,
              child: Container(
                padding: EdgeInsets.only(top: 25),
                child: Text(
                  widget.title,
                  style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.w600,
                      decoration: TextDecoration.none,
                      color: Color(0xFF333130)),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
              ),
            ),

            ///更新内容
            Container(
                width: 280,
                margin: EdgeInsets.only(top: 20, bottom: 20),
                child: Text(
                    widget.message,
                    style: messageStyle,
                    textAlign: TextAlign.left,
                )
            ),

            // 进度条
            Visibility(
              visible: widget.hasLinearProgress,
              child: Container(
                  height: 6,
                  width: 290,
                  margin: EdgeInsets.only(bottom: 20),
                  child: ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: LinearProgressIndicator(
                      value: progress,
                      backgroundColor: Color(0xFFFAF8F7),
                      valueColor: new AlwaysStoppedAnimation<Color>(
                          Color.fromARGB(255, 64, 75, 130)),
                    ),
                  )),
            ),

            ///按钮列表
            Container(
              height: 60,
              child: Row(
                children: <Widget>[
                  Visibility(
                    visible: widget.negativeBtn.isNotEmpty,
                    child: Expanded(
                        child: Container(
                      child: TextButton(
                          onPressed: widget.negativeCallback,
                          child: Text(
                            widget.negativeBtn,
                            style: TextStyle(
                                fontSize: 16,
                                fontWeight: FontWeight.w500,
                                color: Color(0xffDFE2F0)),
                          )),
                    )),
                  ),
                  Container(
                    height: 60,
                    width: 0.5,
                    color: Color(0xffC0C5D6),
                  ),
                  Visibility(
                    visible: widget.positiveBtn.isNotEmpty,
                    child: Expanded(
                        child: Container(
                      child: TextButton(
                          onPressed: widget.positiveBtn != "下载中"
                              ? widget.positiveCallback
                              : () {},
                          child: Text(
                            widget.positiveBtn,
                            style: TextStyle(
                                fontSize: 16,
                                fontWeight: FontWeight.w500,
                                color: Color.fromARGB(255, 64, 75, 130),
                          )),
                    )),
                  )
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

需要说明的是updateProgress方法可以更改UpgradeCard组件显示内容,下文会用到这个方法。

组件展示效果如下图:

WechatIMG175.jpeg

进行新旧版本对比

  1. 配置和获取App本地版本号,首先需要更改项目pubspec.yaml文件中的version字段
//pubspec.yaml
version: 0.0.3
  1. 在项目入口文件中利用package_info_plus库来读取pubspec.yaml配置文件,然后通过shared_preferences库储存App本地版本号。
// main.dart文件
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  await StorageManager.init();   // 本地数据存储配置

  PackageInfo.fromPlatform().then((PackageInfo packageInfo) {   //储存App本地版本号
    String version = packageInfo.version;
    StorageManager.setString("appVersion", version);
  });

  runApp(MyApp());
}
  1. 现在我们已经配置并存储好本地的版本号了,服务端的版本号可以通过检查更新的接口获取到,然后将两者做一个对比来判断是否需要提示用户进行版本更新。
// 版本号对比方法
bool isNewVersion(String netVersion) {
  String localAppVersion = StorageManager.getString("appVersion"); // 本地版本号
  if (netVersion.isEmpty || localAppVersion.isEmpty) return false;
  try {
    List<String> arr1 = netVersion.split('.');
    List<String> arr2 = localAppVersion.split('.');
    int length1 = arr1.length;
    int length2 = arr2.length;
    int minLength = length1 < length2 ? length1 : length2;
    int i = 0;
    for (i; i < minLength; i++) {
      int a = int.parse(arr1[i]);
      int b = int.parse(arr2[i]);
      if (a > b) {
        return true;
      } else if (a < b) {
        return false;
      }
    }
    if (length1 > length2) {
      for (int j = i; j < length1; j++) {
        if (int.parse(arr1[j]) != 0) {
          return true;
        }
      }
      return false;
    } else if (length1 < length2) {
      for (int j = i; j < length2; j++) {
        if (int.parse(arr2[j]) != 0) {
          return false;
        }
      }
      return false;
    }
    return false;
  } catch (err) {
    return false;
  }
}

下载更新和安装应用

现在我们需要对UpgradeCard组件进行二次封装,增加权限判断、路径获取、apk下载、隔离间通信、apk安装等步骤。先列举一下以上功能用到插件是:

  1. 首先处理两个隔离之间的通信(因为UI渲染在主隔离上,而下载时间来自后台隔离)主要用到的是dart内置的dart:isolate库,_bindBackgroundIsolate() 和 _unbindBackgroundIsolate()
  2. 然后获取手机内可存放apk文件的地址利用path_provider库,_apkLocalPath()
  3. 初始化更新标题文案,利用permission_handler库提供一个检查许可权限方法_checkPermission
  4. 组建初始化的时候注册一个下载器的回调函数 FlutterDownloader.registerCallback(...)
  5. 用户点击确认按钮后检查权限,利用flutter_downloader库创建下载任务FlutterDownloader.enqueue(...)
  6. 如果下载失败,则利用记录的downloadId重新发起下载,下载完成利用app_installer库完成apk安装过程 openAPK()

核心代码如下所示(非完整版本)

class UpdateDownloader extends StatefulWidget {
  /// apk更新url
  final String downLoadUrl;

  /// apk更新描述
  final String message;

  /// apk是否强制更新
  final bool isForce;

  UpdateDownloader(
      {required this.downLoadUrl,
      required this.message,
      required this.isForce});

  @override
  _UpdateDownloaderState createState() => _UpdateDownloaderState();
}

class _UpdateDownloaderState extends State<UpdateDownloader> {
  int progress = 0;
  String _localPath = '';
  String downloadId = '';
  DownloadTaskStatus? status;
  ReceivePort _port = ReceivePort();
  UpgradeCard? _upgradeCard;
  
  @override
  void initState() {
    super.initState();
    _bindBackgroundIsolate();
    FlutterDownloader.registerCallback((
      String id, DownloadTaskStatus status, int progress) {
        IsolateNameServer.lookupPortByName("downloader_send_port")
            ?.send([id, status, progress]);
      });
  }

  @override
  void dispose() {
    IsolateNameServer.removePortNameMapping(download_key);
    super.dispose();
  }
  
  //开启监听 两个隔离之间的通信
  void _bindBackgroundIsolate() {
    bool isSuccess =
        IsolateNameServer.registerPortWithName(_port.sendPort, "downloader_send_port");
    if (!isSuccess) {
      _unbindBackgroundIsolate();
      _bindBackgroundIsolate();
      return;
    }
    _port.listen((dynamic data) {
      final taskId = (data as List<dynamic>)[0] as String;
      status = data[1] as DownloadTaskStatus;
      progress = data[2] as int;

      setState(() {
        downloadId = taskId;
      });
      
      // 更新进度条
      _upgradeCard?.updateProgress("检查更新", widget.message, "进行升级", "取消", true,
          double.parse((progress / 100).toStringAsFixed(1)));

      // 处理结果 - 异常
      if (status == DownloadTaskStatus.failed) {
        _upgradeCard?.updateProgress("失败确认", "应用程序下载失败,请重试", "重新下载",
           "取消", false, 0);
      }
      // 处理结果 - 完成
      if (status == DownloadTaskStatus.complete) {
        Future.delayed(new Duration(milliseconds: 500), () async {
          Navigator.of(context).pop();
          openAPK();
        });
      }
    });
  }
  
  //关闭 两个隔离之间的通信
  void _unbindBackgroundIsolate() {
    IsolateNameServer.removePortNameMapping(download_key);
  }
  
  // 初始化弹框文案
  initGeneral() {
    _upgradeCard?.updateProgress("检查更新", "widget.description", "进行升级",
           "取消", widget.isForce, 0);
  }
  
  // 获取下载地址
  Future<String> get _apkLocalPath async {
    final directory = await getExternalStorageDirectory();
    _localPath = directory!.path.toString();
    return _localPath;
  }
  
  //检查权限
  Future<bool> _checkPermission() async {
    PermissionStatus statuses = await Permission.storage.status;
    if (statuses != PermissionStatus.granted) {
      Map<Permission, PermissionStatus> statuses =
          await [Permission.storage].request();
      if (statuses[Permission.storage] == PermissionStatus.granted) {
        return true;
      }
    } else {
      return true;
    }
    return false;
  }
  
  // 停止下载器运行(注销当前taskID)
  closeCallback() {
    Navigator.of(context).pop();
    if (downloadId != '') FlutterDownloader.cancel(taskId: downloadId);
  }
  
  
  //第一步:点击更新按钮
  _updateApplication() async {
    if (status == DownloadTaskStatus.failed) return againDownloader();
    _requestDownload(context, widget.downLoadUrl);
  }
  
  //第二步:flutterDownLoader新建Task
  void _requestDownload(BuildContext context, url) async {
    if (await _checkPermission()) {
      initGeneral();
      downloadId = (await FlutterDownloader.enqueue(
        url: url, // 服务端提供apk下载路径
        savedDir: await _apkLocalPath, // 本地存放路径
        showNotification: true, // 是否显示在通知栏
        openFileFromNotification: true, // 是否在通知栏可以点击打开此文件
      ))!;
    } else {
      ToastUtil.show("权限获取失败");
    }
  }
  
  //第三步:下载失败或开始安装
  void againDownloader() async {
    initGeneral();
    downloadId = (await FlutterDownloader.retry(taskId: downloadId))!;
  }
  
  openAPK() async {
    String path = await _apkLocalPath;
    String last =
        widget.downLoadUrl.substring(widget.downLoadUrl.lastIndexOf("/") + 1);
    AppInstaller.installApk(path + "/" + last)
        .then((result) {})
        .catchError((error) {
      ToastUtil.show(error);
    });
  }
  
  
  @override
  Widget build(BuildContext context) {
    if (_upgradeCard != null) {
      return _upgradeCard!;
    }
    return _upgradeCard = UpgradeCard(
      title: "检查更新",
      message: widget.message,
      positiveBtn: "进行升级",
      negativeBtn: widget.isForce ? "" : "取消",
      positiveCallback: () => _updateApplication(),
      negativeCallback: () => closeCallback(),
      closeCallback: () => closeCallback(),
    );
  }
}

配置系统权限

位于根目录下android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.这里是你的App包名称">
    <!-- 安装.apk文件 -->
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
    
    <application
        android:label="这里是你的App包名称"
        android:icon="@mipmap/ic_launcher">

        <!-- app_installer配置 -->
        <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/provider_paths" />
        </provider>

        <!-- 版本更新配置 (number是可选配置最大并发任务数)--> 
        <provider
            android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
            android:authorities="${applicationId}.flutter_downloader.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_paths"/>
        </provider>
        <provider
            android:name="androidx.work.impl.WorkManagerInitializer"
            android:authorities="${applicationId}.workmanager-init"
            android:enabled="false"
            android:exported="false" />
        <provider
            android:name="vn.hunghd.flutterdownloader.FlutterDownloaderInitializer"
            android:authorities="${applicationId}.flutter-downloader-init"
            android:exported="false">
            <meta-data
                android:name="vn.hunghd.flutterdownloader.MAX_CONCURRENT_TASKS"
                android:value="5" />
        </provider>
        <service android:name="androidx.work.impl.background.systemjob.SystemJobService"
            android:permission="android.permission.BIND_JOB_SERVICE"
            android:exported="true"/>
       
        <!-- 这里还有很多其他默认配置,不做展示-->
    </application>
</manifest>

位于根目录下android/app/src/main下新建文件夹xml,在xml下新建provider_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path path="Android/data/com.这里是你的App包名称/" name="files_root" />
    <external-path path="." name="external_storage_root" />
</paths>