『Flutter-技能篇』實現一套完整的應用內更新

前言

前不久,利用週末時間學習並完成一個簡單的 Flutter 項目 - 簡悅天氣簡約不簡單,豐富不復雜,這是一款簡約風格的 flutter 天氣項目,提供實時、多日、24 小時、颱風路徑、語音播報以及生活指數等服務,支持定位、刪除、搜索等操作。

下圖爲主頁效果,點擊下載 進行體驗:

一款成熟的 APP,爲了保證用戶手上的 apk 始終是最新版本,一方面可以通過發佈到各產商應用商店,依賴其自升級能力完成自更新;或者,自己實現一套應用內更新邏輯,兩者各有利弊。

前者,優勢在於有廠商應用商店自升級通道,可以靜默安裝,用戶基本無感知,但是,如果用戶關閉自更新並且不主動更新,那麼 app 永遠沒有自升級的可能性,而且每家都發布維護,成本也是相當高的。

而後者自己實現,雖然不能靜默安裝,但是可以在用戶每次打開 app 時,根據業務需求或者版本更新,主動推送新版本,提醒用戶相關問題的修復,或者有新的功能,對提升 app 的粘性有很大的幫助。

我們今天將全面完整的實現一套 Flutter 應用內更新流程,涵蓋了前端、服務端和客戶端部分,所以本篇文章主要有兩個主題:

  1. 實現一套完整的應用內更新流程
  2. 實現炫酷的更新彈窗動畫

希望能幫助到有需要的小夥伴。

開始

咱們直接先看一下客戶端完成後的效果:

如上圖所示,在 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 的小項目,作爲自己平常工具、或者爬蟲後數據展示的平臺。前端時間,媳婦懷孕期間,爲了讓我和媳婦能瞭解到離預產期的時間、寶寶狀態和媽媽狀態,怕去了媽媽幫的數據,每天定時定點推送當天的具體數據。不僅如此,娃生下來後,每天也會不間斷的推送娃的出生天數和注意事項,確實還挺實用的。給大家簡單的看一下後臺數據和推送內容~

這是後臺的數據,可以查看和配置各種信息。

這是通過郵箱推送後內容展示。

雖然作爲 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,在頁面中配置相應的數據。

服務端數據下發

後端頁面配置完成,此時需要服務端提供接口給客戶端請求。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 種天氣類型,每次隨機,咱們抽取幾個看一下效果:

背景動畫已經在一篇文章中詳細講解過,感興趣的可以移步到 『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 下載體驗。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章