美文网首页FlutterFlutter圈子Flutter
『Flutter-技能篇』实现一套完整的应用内更新

『Flutter-技能篇』实现一套完整的应用内更新

作者: 下位子 | 来源:发表于2020-10-05 19:43 被阅读0次

    前言

    前不久,利用周末时间学习并完成一个简单的 Flutter 项目 - 简悦天气简约不简单,丰富不复杂,这是一款简约风格的 flutter 天气项目,提供实时、多日、24 小时、台风路径、语音播报以及生活指数等服务,支持定位、删除、搜索等操作。

    下图为主页效果,点击下载 进行体验:

    home

    一款成熟的 APP,为了保证用户手上的 apk 始终是最新版本,一方面可以通过发布到各产商应用商店,依赖其自升级能力完成自更新;或者,自己实现一套应用内更新逻辑,两者各有利弊。

    前者,优势在于有厂商应用商店自升级通道,可以静默安装,用户基本无感知,但是,如果用户关闭自更新并且不主动更新,那么 app 永远没有自升级的可能性,而且每家都发布维护,成本也是相当高的。

    而后者自己实现,虽然不能静默安装,但是可以在用户每次打开 app 时,根据业务需求或者版本更新,主动推送新版本,提醒用户相关问题的修复,或者有新的功能,对提升 app 的粘性有很大的帮助。

    我们今天将全面完整的实现一套 Flutter 应用内更新流程,涵盖了前端、服务端和客户端部分,所以本篇文章主要有两个主题:

    1. 实现一套完整的应用内更新流程
    2. 实现炫酷的更新弹窗动画

    希望能帮助到有需要的小伙伴。

    开始

    咱们直接先看一下客户端完成后的效果:

    app_update

    如上图所示,在 app 打开时,会提升有新版本更新,并告知最新版本的更新内容。

    点击立即更新,会获取配置的下载地址进行下载,并根据当前下载进度呈现水波纹的效果,在加上炫酷的背景动画,整体给人非常炫酷的效果。(水波纹和背景动画后面也会进行讲解)

    更新完成后,直接会跳转到应用的覆盖安装页面,点击安装则可以更新到最新版本啦。

    开始正文,接下来将从 apk 打包,前端页面编写,后端接口返回以及客户端具体逻辑实现,并附带炫酷的更新弹窗效果,完成的呈现一整套流程的运转过程。

    APK 打包

    第一步,虽然简单,但是必不可少,那就是生成最新的 apk 包,不过有不少细节点需要关注。

    1. 在终端使用 flutter build apk --target-platform android-arm64 --split-per-abi 进行打包,任务执行完成后,会在 build/app/outputs/apk/release/app-arm64-v8a-release.apk 下生成 apk 文件。

      这里根据需要编译平台,我这边只需要 arm64 下的 apk,所以中添加 android-arm64 配置。

    2. 记得更新 pubspec.yaml 中的 version 字段。 version: 2.6.0+26 2.6.0 代表版本名称,26 代表版本号,用于判断是否需要提醒更新。

    3. 整理距离上一个版本的 change,看一下主要有哪些更新点,为后面更新说明做准备。

    前端页面配置

    之前自学过一点 python,就决定使用 Django 来完成。

    其实,自己一直有一个 django 的小项目,作为自己平常工具、或者爬虫后数据展示的平台。前端时间,媳妇怀孕期间,为了让我和媳妇能了解到离预产期的时间、宝宝状态和妈妈状态,怕去了妈妈帮的数据,每天定时定点推送当天的具体数据。不仅如此,娃生下来后,每天也会不间断的推送娃的出生天数和注意事项,确实还挺实用的。给大家简单的看一下后台数据和推送内容~

    image

    这是后台的数据,可以查看和配置各种信息。

    image

    这是通过邮箱推送后内容展示。

    虽然作为 Android 开发,但还是要对前后端的知识点有大概了解,这样跟其他的小伙伴沟通起来障碍才会小一点。

    稍微有点扯远了,本篇不介绍 Django 的使用和项目创建,有需要的可以私聊我要课程。

    创建表

    自升级的表数据很简单,只需要 下载地址、版本号和更新点即可。

    class OTAData(models.Model):
        ota_url = models.CharField("下载地址", max_length=256)
        ota_app_code = models.CharField("版本号", max_length=100)
        ota_app_desc = models.TextField("更新点", default="")
    
        class Meta:
            db_table = 'otadata'
            verbose_name = 'OTA数据'
            verbose_name_plural = verbose_name
    

    创建后台展示

    在每个模块下的 admin.py 中配置需要展示的字段,以及排序规则等。

    @admin.register(OTAData)
    class OTAAdmin(ImportExportActionModelAdmin):
        resource_class = OTAResource
        list_display = ("ota_url", "ota_app_code", "ota_app_desc")
    

    配置数据

    部署到线上后,输入绑定的域名或者 IP 地址访问对应的后台 url,在页面中配置相应的数据。

    image

    服务端数据下发

    后端页面配置完成,此时需要服务端提供接口给客户端请求。Django 同样提供能力支持。

    在模块下的 views.py 中配置下发的内容:

    def ota_data(request):
        ota_data = OTAData.objects.all()
        inner_data = {}
        if ota_data.__len__() > 0:
            item = ota_data[0]
            inner_data = {
                "url": item.ota_url,
                "appCode": item.ota_app_code,
                "desc": item.ota_app_desc,
            }
        data = {
            "status": 0,
            "code": 200,
            "data": inner_data,
        }
        return JsonResponse(data, json_dumps_params={'ensure_ascii': False})
    

    通过返回 JsonResponse 对象来控制返回的数据格式,对数据库中的数据进行组装后返回即可。

    然后在模块下的 urls.py 配置地址访问格式:

    path('ota/', ota_data)
    

    发布后,通过访问 http://xxx/ota/ 既可请求到正确的数据。

    {
        "status": 0,
        "code": 200,
        "data": {
            "url": "http://xiaweizi.top/SimplicityWeather-2_6.apk",
            "appCode": "26",
            "desc": "- 新增城市管理动画效果\r\n- 优化搜索结果页展示效果\r\n- 新增炫酷的 demo 入口效果\r\n- 优化背景动画效果"
        }
    }
    

    客户端

    对于 iOS 就直接跳转到 App Store, 本篇讲述 Android 端的实现逻辑。

    对于客户端的逻辑如下:

    1. app 启动时请求 ota 接口
    2. 如果当前版本小于最新版本,则弹窗提示,并展示配置的更新内容
    3. 点击「立即更新」,根据配置的下载地址进行下载,并实时返回下载进度
    4. 下载成功,跳转到更新页面,提醒用户覆盖安装

    请求 ota 接口

      static initOTA() async {
        var otaData = await WeatherApi().getOTA();
        if (otaData != null && otaData["data"] != null) {
          String url = otaData["data"]["url"];
          String desc = otaData["data"]["desc"];
          String versionName = "";
          int appCode = int.parse(otaData["data"]["appCode"]);
          var packageInfo = await PackageInfo.fromPlatform();
          var number = int.parse(packageInfo.buildNumber);
          if (appCode > number) {
            showOTADialog(url, desc, versionName);
          }
        }
      }
    

    成功请求后,根据版本号字段进行判断,如果当前版本小于最新版本,则 showOTADialog

    下载文件并更新进度

    因为下载和安装涉及到文件的存储需要在 AndroidManifest 中声明相应的权限,和动态申请存储权限。

      <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
      <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
      <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
      <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
      <uses-permission android:name="android.permission.INTERNET"/>
    
    String[] permissions = {
            Manifest.permission.WRITE_EXTERNAL_STORAGE
    };
    ActivityCompat.requestPermissions(registrar.activity(), permissions, 0);
    

    前置条件准备好,使用 DownloadManager API 进行下载逻辑处理:

    DownloadManager.Request request = new DownloadManager.Request(Uri.parse(downloadUrl));
    if (headers != null) {
        Iterator<String> jsonKeys = headers.keys();
        while (jsonKeys.hasNext()) {
            String headerName = jsonKeys.next();
            String headerValue = headers.getString(headerName);
            request.addRequestHeader(headerName, headerValue);
        }
    }
    request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE);
    request.setDestinationUri(fileUri);
    final DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
    final long downloadId = manager.enqueue(request);
    

    在子线程中周期性的查询当前下载的进度,并告诉到 Flutter 端。

        private void trackDownloadProgress(final long downloadId, final DownloadManager manager) {
            Log.d(TAG, "OTA UPDATE TRACK DOWNLOAD STARTED " + downloadId);
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Log.d(TAG, "OTA UPDATE TRACK DOWNLOAD THREAD STARTED " + downloadId);
                    boolean downloading = true;
                    boolean hasStatus = false;
                    long downloadStart = System.currentTimeMillis();
                    while (downloading) {
                        DownloadManager.Query q = new DownloadManager.Query();
                        q.setFilterById(downloadId);
                        Cursor c = manager.query(q);
                        if (c.moveToFirst()) {
                            hasStatus = true;
                            long bytes_downloaded = c.getLong(c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
                            long bytes_total = c.getLong(c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
                            if (progressSink != null && bytes_total > 0) {
                                Message message = new Message();
                                Bundle data = new Bundle();
                                data.putLong(BYTES_DOWNLOADED, bytes_downloaded);
                                data.putLong(BYTES_TOTAL, bytes_total);
                                message.setData(data);
                                handler.sendMessage(message);
                            }
                            c.close();
                            try {
                                Thread.sleep(250);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        } else {
                            long duration = System.currentTimeMillis() - downloadStart;
                            if (!hasStatus && duration > MAX_WAIT_FOR_DOWNLOAD_START) {
                                downloading = false;
                                Log.d(TAG, "OTA UPDATE FAILURE: DOWNLOAD DID NOT START AFTER 5000ms");
                                Message message = new Message();
                                Bundle data = new Bundle();
                                data.putString(ERROR, "DOWNLOAD DID NOT START AFTER 5000ms");
                                message.setData(data);
                                handler.sendMessage(message);
                            }
                        }
                    }
                }
            }).start();
        }
    

    安装最新的 apk

    下载后跳转到安装页面进行安装。

    Intent intent;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        //AUTHORITY NEEDS TO BE THE SAME ALSO IN MANIFEST
        Uri apkUri = FileProvider.getUriForFile(context, androidProviderAuthority, downloadedFile);
        intent = new Intent(Intent.ACTION_INSTALL_PACKAGE);
        intent.setData(apkUri);
        intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    } else {
        intent = new Intent(Intent.ACTION_VIEW);
        intent.setDataAndType(fileUri, "application/vnd.android.package-archive");
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    }
    context.startActivity(intent);
    

    到此完整的更新流程已结束,当然还有很多可以优化的地方:

    1. 每次请求后的 ota 数据可以缓存到本地,下次可以快速的弹出提示弹窗
    2. 当用户点击关闭后,可以自定义策略,比如几天内不再提示
    3. 在特殊页面,比如关于页面,以红点的形式提醒用户有新版本更新,这种侵入性比较低,弱提醒,对用户感染性很小。

    炫酷的更新弹窗

    本章节属于彩蛋环节,升级弹窗千篇一律,如何结合自己的业务达到完美的效果,相信接下来会给出答案。

    作为天气 app,那整体的设计风格当然要保持一致,之前特定花了一点时间,将炫酷的天气动态背景抽成插件,并发布 flutter_weather_bg,同样咱们可以把他应用到弹窗上,再加上水波纹的动画,呈现出形象生动有趣的更新效果,让用户等待更新的过程不再枯燥。

    背景一共 15 种天气类型,每次随机,咱们抽取几个看一下效果:

    sun_night snow thunder

    背景动画已经在一篇文章中详细讲解过,感兴趣的可以移步到 『Flutter-插件篇』实现一款超酷的动态天气背景插件 查看。

    看一下水波纹动画如何实现,核心思想就是正弦函数。

    初始化动画

      progressController = AnimationController(
        vsync: this,
        duration: Duration(milliseconds: 3000),
      );
    
      waveController = AnimationController(
        vsync: this,
        duration: Duration(milliseconds: 800),
      );
      progressController.animateTo(widget.progress);
      waveController.repeat();
    

    waveController 是无限循环的动画,营造水波纹一直涌动的效果。

    progressController 则是控制水波纹上升的动画效果。

      @override
      void didUpdateWidget(WaveProgress oldWidget) {
        super.didUpdateWidget(oldWidget);
        if (oldWidget.progress != widget.progress) {
          progressController.animateTo(widget.progress / 100.0);
        }
      }
    

    progress 发生改变时执行动画。

    水波纹绘制

    整体由前后两个水波纹组成,咱们只挑一个讲解

    double progress = _progressAnimation.value;
    double frequency = 3.2;
    double waveHeight = 4.0;
    double currentHeight = (1 - progress) * size.height;
    
    Path path = Path();
    path.moveTo(0.0, currentHeight);
    for (double i = 0.0; i < size.width; i++) {
      path.lineTo(
          i,
          currentHeight +
              sin((i / size.width * 2 * pi * frequency) +
                      (_waveAnimation.value * 2 * pi) +
                      pi * 1) *
                  waveHeight);
    }
    
    path.lineTo(size.width, size.height);
    path.lineTo(0.0, size.height);
    path.close();
    canvas.drawPath(path, bottomPaint);
    

    frequency 控制周期,越大越密集

    waveHeight 控制高度,越大越陡

    currentHeight 根据当前进度,绘制起始高度

    核心的绘制就在使用 sin 函数,根据配置以及当前动画进度作为 x 值算出 y 值,遍历宽度上所有的点,绘制出水波纹的效果

    for (double i = 0.0; i < size.width; i++) {
      path.lineTo(
          i,
          currentHeight +
              sin((i / size.width * 2 * pi * frequency) +
                      (_waveAnimation.value * 2 * pi) +
                      pi * 1) *
                  waveHeight);
    }
    

    好了,到此文章结束,虽然不算复杂,但是完整的讲述了一套应用内更新,从前端到服务端,再到客户端的逻辑,感兴趣的可以到 SimplicityWeather 下载体验。

    相关文章

      网友评论

        本文标题:『Flutter-技能篇』实现一套完整的应用内更新

        本文链接:https://www.haomeiwen.com/subject/ujnauktx.html