背景

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

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

功能清单(按步骤)

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

UpgradeCard组件

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

UpgradeCard组件代码

 1import 'package:flutter/material.dart';
 2import 'package:flutter_svg/flutter_svg.dart';
 3
 4class UpgradeCard extends StatefulWidget {
 5  
 6  String title;
 7  
 8  String message;
 9  
10  String positiveBtn;
11  
12  String negativeBtn;
13  
14  final GestureTapCallback positiveCallback;
15  
16  final GestureTapCallback negativeCallback;
17  
18  bool hasLinearProgress;
19
20  UpgradeCard(
21      {this.title = "",
22      this.message = "",
23      this.positiveBtn = "",
24      this.negativeBtn = "",
25      required this.positiveCallback,
26      required this.negativeCallback,
27      this.hasLinearProgress = false});
28
29  final _upgradeCardState = _UpgradeCardState();
30
31  @override
32  _UpgradeCardState createState() => _upgradeCardState;
33
34  
35  void updateProgress(String title, String message, String positiveBtn,
36          String negativeBtn, bool hasLinearProgress, double progress) =>
37      _upgradeCardState.updateProgress(title, message, positiveBtn,
38          negativeBtn, hasLinearProgress, progress);
39}
40
41class _UpgradeCardState extends State<UpgradeCard> {
42  
43  double progress = 0;
44
45  
46  void updateProgress(String title, String message, String positiveBtn,
47      String negativeBtn, bool hasLinearProgress, double progress) {
48    setState(() {
49      widget.title = title;
50      widget.message = message;
51      widget.positiveBtn = positiveBtn;
52      widget.negativeBtn = negativeBtn;
53      widget.hasLinearProgress = hasLinearProgress;
54      progress = progress;
55    });
56  }
57
58  @override
59  Widget build(BuildContext context) {
60    var messageStyle = TextStyle(
61        fontSize: 14,
62        fontWeight: FontWeight.w400,
63        decoration: TextDecoration.none,
64        color: Color(0xFF333130),
65        height: 1.6);
66
67    return Center(
68      child: Container(
69        width: 320,
70        padding: EdgeInsets.only(bottom: 17),
71        decoration: BoxDecoration(
72            color: Colors.white, borderRadius: BorderRadius.circular(8)),
73        child: Column(
74          mainAxisSize: MainAxisSize.min,
75          children: <Widget>[
76            
77            Container(
78              padding: EdgeInsets.only(bottom: 8),
79              child: ClipRRect(
80                borderRadius: BorderRadius.circular(8),
81                child: Image.asset(
82                  "assets/images/updateBg.png",
83                  width: 320,
84                ),
85              ),
86            ),
87
88            
89            Visibility(
90              visible: widget.title.isNotEmpty,
91              child: Container(
92                padding: EdgeInsets.only(top: 25),
93                child: Text(
94                  widget.title,
95                  style: TextStyle(
96                      fontSize: 18,
97                      fontWeight: FontWeight.w600,
98                      decoration: TextDecoration.none,
99                      color: Color(0xFF333130)),
100                  maxLines: 1,
101                  overflow: TextOverflow.ellipsis,
102                ),
103              ),
104            ),
105
106            
107            Container(
108                width: 280,
109                margin: EdgeInsets.only(top: 20, bottom: 20),
110                child: Text(
111                    widget.message,
112                    style: messageStyle,
113                    textAlign: TextAlign.left,
114                )
115            ),
116
117            
118            Visibility(
119              visible: widget.hasLinearProgress,
120              child: Container(
121                  height: 6,
122                  width: 290,
123                  margin: EdgeInsets.only(bottom: 20),
124                  child: ClipRRect(
125                    borderRadius: BorderRadius.circular(8),
126                    child: LinearProgressIndicator(
127                      value: progress,
128                      backgroundColor: Color(0xFFFAF8F7),
129                      valueColor: new AlwaysStoppedAnimation<Color>(
130                          Color.fromARGB(255, 64, 75, 130)),
131                    ),
132                  )),
133            ),
134
135            
136            Container(
137              height: 60,
138              child: Row(
139                children: <Widget>[
140                  Visibility(
141                    visible: widget.negativeBtn.isNotEmpty,
142                    child: Expanded(
143                        child: Container(
144                      child: TextButton(
145                          onPressed: widget.negativeCallback,
146                          child: Text(
147                            widget.negativeBtn,
148                            style: TextStyle(
149                                fontSize: 16,
150                                fontWeight: FontWeight.w500,
151                                color: Color(0xffDFE2F0)),
152                          )),
153                    )),
154                  ),
155                  Container(
156                    height: 60,
157                    width: 0.5,
158                    color: Color(0xffC0C5D6),
159                  ),
160                  Visibility(
161                    visible: widget.positiveBtn.isNotEmpty,
162                    child: Expanded(
163                        child: Container(
164                      child: TextButton(
165                          onPressed: widget.positiveBtn != "下载中"
166                              ? widget.positiveCallback
167                              : () {},
168                          child: Text(
169                            widget.positiveBtn,
170                            style: TextStyle(
171                                fontSize: 16,
172                                fontWeight: FontWeight.w500,
173                                color: Color.fromARGB(255, 64, 75, 130),
174                          )),
175                    )),
176                  )
177                ],
178              ),
179            ),
180          ],
181        ),
182      ),
183    );
184  }
185}
186

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

组件展示效果如下图:

进行新旧版本对比

  1. 配置和获取App本地版本号,首先需要更改项目pubspec.yaml文件中的version字段
 1//pubspec.yaml
 2version: 0.0.3
  1. 在项目入口文件中利用package_info_plus库来读取pubspec.yaml配置文件,然后通过shared_preferences库储存App本地版本号。
 1void main() async {
 2  WidgetsFlutterBinding.ensureInitialized();
 3  
 4  await StorageManager.init();   
 5
 6  PackageInfo.fromPlatform().then((PackageInfo packageInfo) {   
 7    String version = packageInfo.version;
 8    StorageManager.setString("appVersion", version);
 9  });
10
11  runApp(MyApp());
12}
  1. 现在我们已经配置并存储好本地的版本号了,服务端的版本号可以通过检查更新的接口获取到,然后将两者做一个对比来判断是否需要提示用户进行版本更新。
 1bool isNewVersion(String netVersion) {
 2  String localAppVersion = StorageManager.getString("appVersion"); 
 3  if (netVersion.isEmpty || localAppVersion.isEmpty) return false;
 4  try {
 5    List<String> arr1 = netVersion.split('.');
 6    List<String> arr2 = localAppVersion.split('.');
 7    int length1 = arr1.length;
 8    int length2 = arr2.length;
 9    int minLength = length1 < length2 ? length1 : length2;
10    int i = 0;
11    for (i; i < minLength; i++) {
12      int a = int.parse(arr1[i]);
13      int b = int.parse(arr2[i]);
14      if (a > b) {
15        return true;
16      } else if (a < b) {
17        return false;
18      }
19    }
20    if (length1 > length2) {
21      for (int j = i; j < length1; j++) {
22        if (int.parse(arr1[j]) != 0) {
23          return true;
24        }
25      }
26      return false;
27    } else if (length1 < length2) {
28      for (int j = i; j < length2; j++) {
29        if (int.parse(arr2[j]) != 0) {
30          return false;
31        }
32      }
33      return false;
34    }
35    return false;
36  } catch (err) {
37    return false;
38  }
39}

下载更新和安装应用

现在我们需要对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()

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

 1class UpdateDownloader extends StatefulWidget {
 2  
 3  final String downLoadUrl;
 4
 5  
 6  final String message;
 7
 8  
 9  final bool isForce;
10
11  UpdateDownloader(
12      {required this.downLoadUrl,
13      required this.message,
14      required this.isForce});
15
16  @override
17  _UpdateDownloaderState createState() => _UpdateDownloaderState();
18}
19
20class _UpdateDownloaderState extends State<UpdateDownloader> {
21  int progress = 0;
22  String _localPath = '';
23  String downloadId = '';
24  DownloadTaskStatus? status;
25  ReceivePort _port = ReceivePort();
26  UpgradeCard? _upgradeCard;
27  
28  @override
29  void initState() {
30    super.initState();
31    _bindBackgroundIsolate();
32    FlutterDownloader.registerCallback((
33      String id, DownloadTaskStatus status, int progress) {
34        IsolateNameServer.lookupPortByName("downloader_send_port")
35            ?.send([id, status, progress]);
36      });
37  }
38
39  @override
40  void dispose() {
41    IsolateNameServer.removePortNameMapping(download_key);
42    super.dispose();
43  }
44  
45  
46  void _bindBackgroundIsolate() {
47    bool isSuccess =
48        IsolateNameServer.registerPortWithName(_port.sendPort, "downloader_send_port");
49    if (!isSuccess) {
50      _unbindBackgroundIsolate();
51      _bindBackgroundIsolate();
52      return;
53    }
54    _port.listen((dynamic data) {
55      final taskId = (data as List<dynamic>)[0] as String;
56      status = data[1] as DownloadTaskStatus;
57      progress = data[2] as int;
58
59      setState(() {
60        downloadId = taskId;
61      });
62      
63      
64      _upgradeCard?.updateProgress("检查更新", widget.message, "进行升级", "取消", true,
65          double.parse((progress / 100).toStringAsFixed(1)));
66
67      
68      if (status == DownloadTaskStatus.failed) {
69        _upgradeCard?.updateProgress("失败确认", "应用程序下载失败,请重试", "重新下载",
70           "取消", false, 0);
71      }
72      
73      if (status == DownloadTaskStatus.complete) {
74        Future.delayed(new Duration(milliseconds: 500), () async {
75          Navigator.of(context).pop();
76          openAPK();
77        });
78      }
79    });
80  }
81  
82  
83  void _unbindBackgroundIsolate() {
84    IsolateNameServer.removePortNameMapping(download_key);
85  }
86  
87  
88  initGeneral() {
89    _upgradeCard?.updateProgress("检查更新", "widget.description", "进行升级",
90           "取消", widget.isForce, 0);
91  }
92  
93  
94  Future<String> get _apkLocalPath async {
95    final directory = await getExternalStorageDirectory();
96    _localPath = directory!.path.toString();
97    return _localPath;
98  }
99  
100  
101  Future<bool> _checkPermission() async {
102    PermissionStatus statuses = await Permission.storage.status;
103    if (statuses != PermissionStatus.granted) {
104      Map<Permission, PermissionStatus> statuses =
105          await [Permission.storage].request();
106      if (statuses[Permission.storage] == PermissionStatus.granted) {
107        return true;
108      }
109    } else {
110      return true;
111    }
112    return false;
113  }
114  
115  
116  closeCallback() {
117    Navigator.of(context).pop();
118    if (downloadId != '') FlutterDownloader.cancel(taskId: downloadId);
119  }
120  
121  
122  
123  _updateApplication() async {
124    if (status == DownloadTaskStatus.failed) return againDownloader();
125    _requestDownload(context, widget.downLoadUrl);
126  }
127  
128  
129  void _requestDownload(BuildContext context, url) async {
130    if (await _checkPermission()) {
131      initGeneral();
132      downloadId = (await FlutterDownloader.enqueue(
133        url: url, 
134        savedDir: await _apkLocalPath, 
135        showNotification: true, 
136        openFileFromNotification: true, 
137      ))!;
138    } else {
139      ToastUtil.show("权限获取失败");
140    }
141  }
142  
143  
144  void againDownloader() async {
145    initGeneral();
146    downloadId = (await FlutterDownloader.retry(taskId: downloadId))!;
147  }
148  
149  openAPK() async {
150    String path = await _apkLocalPath;
151    String last =
152        widget.downLoadUrl.substring(widget.downLoadUrl.lastIndexOf("/") + 1);
153    AppInstaller.installApk(path + "/" + last)
154        .then((result) {})
155        .catchError((error) {
156      ToastUtil.show(error);
157    });
158  }
159  
160  
161  @override
162  Widget build(BuildContext context) {
163    if (_upgradeCard != null) {
164      return _upgradeCard!;
165    }
166    return _upgradeCard = UpgradeCard(
167      title: "检查更新",
168      message: widget.message,
169      positiveBtn: "进行升级",
170      negativeBtn: widget.isForce ? "" : "取消",
171      positiveCallback: () => _updateApplication(),
172      negativeCallback: () => closeCallback(),
173      closeCallback: () => closeCallback(),
174    );
175  }
176}

配置系统权限

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

 1<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 2    package="com.这里是你的App包名称">
 3    
 4    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
 5    
 6    <application
 7        android:label="这里是你的App包名称"
 8        android:icon="@mipmap/ic_launcher">
 9
10        
11        <provider
12            android:name="androidx.core.content.FileProvider"
13            android:authorities="${applicationId}.fileProvider"
14            android:exported="false"
15            android:grantUriPermissions="true">
16            <meta-data
17                android:name="android.support.FILE_PROVIDER_PATHS"
18                android:resource="@xml/provider_paths" />
19        </provider>
20
21         
22        <provider
23            android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
24            android:authorities="${applicationId}.flutter_downloader.provider"
25            android:exported="false"
26            android:grantUriPermissions="true">
27            <meta-data
28                android:name="android.support.FILE_PROVIDER_PATHS"
29                android:resource="@xml/provider_paths"/>
30        </provider>
31        <provider
32            android:name="androidx.work.impl.WorkManagerInitializer"
33            android:authorities="${applicationId}.workmanager-init"
34            android:enabled="false"
35            android:exported="false" />
36        <provider
37            android:name="vn.hunghd.flutterdownloader.FlutterDownloaderInitializer"
38            android:authorities="${applicationId}.flutter-downloader-init"
39            android:exported="false">
40            <meta-data
41                android:name="vn.hunghd.flutterdownloader.MAX_CONCURRENT_TASKS"
42                android:value="5" />
43        </provider>
44        <service android:name="androidx.work.impl.background.systemjob.SystemJobService"
45            android:permission="android.permission.BIND_JOB_SERVICE"
46            android:exported="true"/>
47       
48        
49    </application>
50</manifest>

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

 1<?xml version="1.0" encoding="utf-8"?>
 2<paths>
 3    <external-path path="Android/data/com.这里是你的App包名称/" name="files_root" />
 4    <external-path path="." name="external_storage_root" />
 5</paths>
个人笔记记录 2021 ~ 2025