玩一玩Android下載框架

crazysunj發表於2018-08-19

前言

繼上篇《不一樣的HTTP快取體驗》已經有一段時間了,一直沒寫教學型文章不是因為太忙,想了很久不知道以什麼為主題,有個哥們看了我的開源專案CrazyDaily,好像對下載挺感興趣,那我就寫一篇吧!下載框架似乎是我們入門必學的一個技術點,因為它囊括了很多方面的知識,優秀的開源下載框架非常多,各有千秋。那麼,此刻,大家一起跟著我來打造一款下載框架!準備好了嗎?

玩一玩Android下載框架

效果

一貫作風!No picture,say a J8!

玩一玩Android下載框架

實戰

我們從效果圖上簡單分析一下執行流程,首先開啟二維碼,掃描一個下載連結;解析到下載連結跳轉下載中轉頁並彈出下載資訊確認框;確認下載,通知欄回顯進度;下載完成,點選可檢視。

OK,很實用的一個流程,掃碼和下載可以算是我們天天都會用到的兩個技術。

掃碼

沒什麼懸念,我選擇zxing,谷歌出品,必屬精品。這裡我選擇zxing-android-embedded,它是基於zxing簡單封裝,可擴充套件,用不用其實無所謂,我們的重點並不在這裡。我們看到的二維碼效果,本身並不是這樣的,我寫的效果是模仿微信的,那是如何做的呢?文末告訴你答案。

zxing的使用很簡單,這樣就調起掃碼介面了。記得請求攝像頭許可權哦。

new IntentIntegrator(this)
        .setCaptureActivity(ScannerActivity.class).initiateScan();
複製程式碼

ScannerActivity是我們自定義的掃碼介面,支援從本地圖片中掃碼,實現過程忽略。掃完碼肯定會回撥一串字串,例如這裡我們是一個下載連結,那麼在哪裡回撥呢?

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    IntentResult result = IntentIntegrator.parseActivityResult(resultCode, data);
    String scanResult = result.getContents();
    if (scanResult != null) {
        BrowserActivity.start(this, scanResult);
    }
}
複製程式碼

回撥結果谷歌都給你封裝好了,你直接拿來用就是,這裡我們需要那串字串。大家應該知道了,我們的中轉頁其實是個Web頁。

玩一玩Android下載框架

中轉頁

那麼為什麼要用Web頁當中轉頁呢?其實感覺用中轉頁可能不太合適,掃碼結果頁可能更合適一點。我們掃碼結果多種多樣,常見的是一個http連結,其又分為網頁和下載連結。如果人工去判斷實在太麻煩了,即使只有http的。我們可以簡單把字串分為兩類,一類是符合URI格式的,另一類是不符合的。如果不符合URI格式,那麼我們直接把它當成文字處理;反之,我們直接用WebView去解析。

前面已經說了,即使是隻有http的也是很麻煩的,你如何判斷一個連結是下載連結呢?靠字尾名嗎?不存在的,唯一的辦法就是連線之後解析。實在太麻煩了,如果玩過WebView的同學肯定知道WebView支援下載監聽的,瀏覽器核心會幫我們去解析,我們只要實現這一的監聽就結束了。

setDownloadListener(new DownloadListener() {
    @Override
    public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) {
        if (mDownloadCallback != null) {
            mDownloadCallback.onDownload(url, contentLength);
        }
    }
});
複製程式碼

OK,回撥的肯定在我們的中轉頁:

mWebView.setDownloadCallback((url, contentLength) ->
        new AlertDialog.Builder(this, R.style.NormalDialog)
                .setTitle("提示")
                .setCancelable(false)
                .setMessage(String.format("下載連結:%s\n下載大小:%sMB", url, StorageUtil.byteToMB(contentLength)))
                .setNegativeButton("不下", null)
                .setPositiveButton("下載", (dialogInterface, i) -> DownloadService.start(this, url))
                .show());
複製程式碼

簡單的一個彈框,確認下載跳轉我們的下載服務。

但你以為這真的會彈出來來嗎?

玩一玩Android下載框架

測試中有些手機並不會彈出(並沒有回撥DownloadListener),但如果是正常網頁可以載入出來且點選網頁中的下載連結也可以下載,如果是這樣就好辦了,其實只要在頁面載入前,再載入一次就完事了。例如這樣:

@Override
public void onPageStarted(WebView webView, String s, Bitmap bitmap) {
    if (!isLoaded) {
        isLoaded = true;
        webView.loadUrl(s);
    }
    super.onPageStarted(webView, s, bitmap);
}
複製程式碼

記得用isLoaded去控制哦,不然是個閉環,仔細想想,哈哈。

核心下載

關於下載,我們這裡設計成啟動服務在後臺下載。

網路請求

mPresenter.download(url, FileUtil.getDownloadFile(this)); // 啟動下載
複製程式碼

Presenter連線我們的Model層,

mDownloadUseCase.execute(DownloadUseCase.Params.get(url, saveFile), new BaseSubscriber<File>() {
    @Override
    public void onNext(File file) {
        mView.onSuccess(file);
    }

    @Override
    public void onError(Throwable e) {
        super.onError(e);
        mView.onFailed(e);
    }

    @Override
    public void onComplete() {
        mView.onComplete();
    }
});
複製程式碼

domain層呼叫我們的data獲取下載資料,

@Override
protected Flowable<File> buildUseCaseObservable(Params params) {
    return mDownloadRepository.download(params.url, params.saveFileDir);
}

@Override
public Flowable<File> download(String url, File saveFileDir) {
    return mDownloadService.download(url)
            .observeOn(Schedulers.io())
            .map(response -> convertFile(saveFileDir, response))
            .subscribeOn(Schedulers.io())
            .unsubscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread());
}
複製程式碼

最終還是我們熟悉的Retrofit,哈哈。

@Streaming
@GET
Flowable<Response<ResponseBody>> download(@Url String url);
複製程式碼

簡單介紹下Streaming和Url註解,Streaming可以響應立即以位元組流返回,預設會把資料全部載入到記憶體中,所以可以用於大檔案下載;Url可以指定請求路徑,覆蓋本身的baseurl。

回顯進度

既然我們要回傳進度,retrofit是如何回撥進度的呢?準確點應該是okhttp,okhttp一個比較核心的東西叫Interceptor,我們可以通過這知道當前下載進度。

private static class ProgressInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Response originalResponse = chain.proceed(chain.request());
        return originalResponse.newBuilder()
                .body(new ProgressResponseBody(originalResponse.body()))
                .build();
    }
}
複製程式碼

重新封裝我們的Response,如果對攔截不太瞭解的可以看看我這篇文章《玩一玩OkHttp快取原始碼》,然後重新封裝body,

private static class ProgressResponseBody extends ResponseBody {
    ...
    public ProgressResponseBody(ResponseBody responseBody) {
        this.responseBody = responseBody;
    }
    ...
    @Override
    public BufferedSource source() {
        if (bufferedSource == null) {
            bufferedSource = Okio.buffer(source(contentLength(), responseBody.source()));
        }
        return bufferedSource;
    }

    private Source source(long contentLength, Source source) {
        return new ForwardingSource(source) {
            long bytesReaded = 0;
            @Override
            public long read(Buffer sink, long byteCount) throws IOException {
                long bytesRead = super.read(sink, byteCount);
                bytesReaded += bytesRead == -1 ? 0 : bytesRead;
                RxBus.getDefault().post(String.valueOf(taskId), new DownloadEvent(contentLength, bytesReaded));
                return bytesRead;
            }
        };
    }
}
複製程式碼

source表示輸入流,不懂的可以看看我這篇《玩一玩Okio原始碼》,當初分析okhttp原始碼的時候,有人不太理解,故後面補了這麼一篇來分析okio的原始碼。如果已經瞭解的同學,肯定就懂了,bytesRead就是我們每次讀入的位元組,我們建立成員變數bytesReaded支援每次回撥就加上bytesRead來統計當前已經讀入的總位元組,contentLength方法就是整個需要讀入的總位元組。而這裡我們通過RxBus來把這個資料分發出去。RxBus底層其實就是RxJava,感興趣自己去看看,這裡不多介紹。

但這其實是有問題的,敏銳的同學已經發現了,但先不說問題,我們繼續後面的步驟。

剛剛談到RxBus,既然有釋出方,那麼必須要有個訂閱方,在我們的DownloadPresenter中,

mDownloadUseCase.execute(RxBus.getDefault().toFlowable(tag, DownloadEvent.class), new DisposableSubscriber<DownloadEvent>() {
    @Override
    public void onNext(DownloadEvent downloadEvent) {
        final int progress = (int) (downloadEvent.loaded * 100f / downloadEvent.total + 0.5f);
        mView.onProgress(progress);
    }
    ...
});
複製程式碼

OK,很簡單就是回撥給我們的View層。再來看看我們的View層怎麼寫的。

@Override
public void onProgress(int progress) {
    mNotificationBuilder.setContentText(String.format(Locale.getDefault(), "正在下載:%d%%", progress))
            .setProgress(100, progress, false);
    mNotificationManager.notify(NOTIFICATION_ID, mNotificationBuilder.build());
}
複製程式碼

通知欄

不停改變通知欄的進度,那麼通知欄如何建立呢?

private void initNotification() {
    mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // 適配通知欄8.0
        assert mNotificationManager != null;
        // 需要建立通知渠道,比如我們這裡的通知欄是用於下載監聽的
        NotificationChannel channel = mNotificationManager.getNotificationChannel(CHANNEL_ID_DOWNLOAD);
        if (channel == null) {
            channel = new NotificationChannel(CHANNEL_ID_DOWNLOAD, "下載通知", NotificationManager.IMPORTANCE_MIN);
            mNotificationManager.createNotificationChannel(channel);
        }
        if (channel.getImportance() == NotificationManager.IMPORTANCE_NONE) {
            // 通知欄許可權沒開啟,可直接跳轉到許可權設定介面
            Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
            intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
            intent.putExtra(Settings.EXTRA_CHANNEL_ID, channel.getId());
            startActivity(intent);
            Toast.makeText(this, "設定好通知欄許可權,請重新下載", Toast.LENGTH_SHORT).show();
            stopSelf();
        }
    }
    // 初始化下載通知欄
    mNotificationBuilder = new NotificationCompat.Builder(this, CHANNEL_ID_DOWNLOAD)
            .setContentText("正在下載")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setOngoing(true)
            .setWhen(System.currentTimeMillis());
    mNotificationManager.notify(NOTIFICATION_ID, mNotificationBuilder.build());
    Toast.makeText(this, "正在下載,可在通知欄檢視進度哦", Toast.LENGTH_SHORT).show();
}
複製程式碼

該註釋的我都註釋了,下載進度我們已經處理好了,那麼下載完,我們如何處理呢?我們肯定是要將檔案儲存在本地,然後通知欄告知下載完成,點選檢視。

儲存檔案

因為儲存檔案是統一邏輯,所以寫在data層,還記得DownloadDataRepository的download方法嗎?再來一遍:

@Override
public Flowable<File> download(String url, File saveFileDir) {
    return mDownloadService.download(url)
            .observeOn(Schedulers.io())
            .map(response -> convertFile(saveFileDir, response))
            .subscribeOn(Schedulers.io())
            .unsubscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread());
}
複製程式碼

顯然convertFile方法就是儲存檔案的關鍵方法啦,大家一起來看看吧:

@Nullable
private File convertFile(File saveFileDir, Response<ResponseBody> response) {
    final ResponseBody responseBody = response.body();
    ...
    try {
        File saveFile = new File(saveFileDir, getFileName(response));
        bufferedSink = Okio.buffer(Okio.sink(saveFile));
        source = Okio.source(responseBody.byteStream());
        bufferedSink.writeAll(source);
        bufferedSink.flush();
        return saveFile;
    } catch (IOException e) {
       	...
    } finally {
        ...
    }
    return null;
}
複製程式碼

熟悉okio的同學已經知道怎麼回事了,不熟悉的也沒關係,其實就是用okio寫入我們要儲存的檔案裡,沒什麼難點。這裡的難點其實是如何得到儲存檔名,當然最簡單的就是使用者自己去設定。但我們要的肯定是自己動啊。

玩一玩Android下載框架

stop!我們來看看getFileName方法。

@NonNull
private String getFileName(Response<ResponseBody> response) {
    final okhttp3.Response raw = response.raw();
    // 得到contentDisposition
    String contentDisposition = raw.header("Content-Disposition");
    String fileName;
    if (TextUtils.isEmpty(contentDisposition)) {
        // 如果為空,那麼我們就不能在這裡取了,咋辦?只能靠擷取下載連結了,記得把引數截掉。	
        String file = raw.request().url().url().getFile();
        fileName = file.substring(file.lastIndexOf("/") + 1, file.contains("?") ? file.indexOf("?") : file.length());
    } else {
        // 如果存在,那麼很簡單了,filename後面的就是檔案的名字
        try {
            fileName = URLDecoder.decode(contentDisposition.substring(contentDisposition.indexOf("filename=") + 9), "UTF-8");
            fileName = fileName.split("\"")[fileName.contains("\"") ? 1 : 0];
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
            fileName = contentDisposition.substring(contentDisposition.indexOf("filename=") + 9);
            fileName = fileName.split("\"")[fileName.contains("\"") ? 1 : 0];
        }
    }
    return fileName;
}
複製程式碼

這裡其實也有坑點,下面再說。data層處理完後,最終還是會回撥到View層。

@Override
public void onSuccess(File saveFile) {
    Toast.makeText(this, "下載完成,儲存路徑:" + saveFile.getAbsolutePath(), Toast.LENGTH_SHORT).show();
    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.addCategory(Intent.CATEGORY_DEFAULT);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    Uri uri;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        uri = FileProvider.getUriForFile(this, getString(R.string.file_provider_authorities), saveFile);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    } else {
        uri = Uri.fromFile(saveFile);
    }
    intent.setData(uri);
    PendingIntent pendingintent = PendingIntent.getActivity(this, 0, intent, PendingIntent
            .FLAG_UPDATE_CURRENT);
    mNotificationBuilder.setContentIntent(pendingintent);
}
複製程式碼

設定通知欄的跳轉連結,url指向我們的儲存檔案,注意,這裡我們要相容7.0。現在都9.0了,這應該不需要我說了吧?不存在的,即使10.0,我估計我5.0還沒搞清楚。

玩一玩Android下載框架

擴充套件

一個簡單的下載框架我們算是完成了,但還是存在多個小毛病,說是小毛病,其實是致命的,哈哈。

多工

第一個我留下的問題是,使用者多次掃碼多次建立下載任務會如何?我們的RxBus是全域性的且事件並沒有標識任務,也就是說所有的任務都會在通知欄回撥,那效果不堪入目啊。有的同學我猜會順著這思路問,那下載任務也會建立多個嗎?會不會只有一個啊?提問題很好,但基礎貌似不過關,Service多次呼叫startService啟動,那麼onCreate只會執行一次,但onStartCommand會執行多次。

對於替換掉RxBus而使用listener我更傾向RxBus事件加個標識,標識當前的任務,這樣做正好與通知欄的標識相對應。假設是這樣,那麼用什麼來當標識?簡單點就是用時間戳,但是我們的通知欄ID貌似是int型別,MMP。想了下,還是老辦法,用UUID,大概率是不會重複的,那麼重複了怎麼辦?再獲取一次。那麼,我們可以定下獲取任務ID或者說通知欄ID的方法。

private int getTaskId() {
    do {
        int taskId = UUID.randomUUID().hashCode();
        if (mTaskIds.indexOfKey(taskId) == -1) {
            return taskId;
        }
    } while (true);
}
複製程式碼

由於我們的通知欄標識跟我們的任務ID一致,故新增新的資料儲存集合用來儲存我們的通知欄例項。

private SparseArray<DownloadInfo> mTaskIds = new SparseArray<>();

private class DownloadInfo {
    NotificationCompat.Builder builder;
    boolean isComplete; // 用於全部下載完成,自動關閉service
    ...
}
複製程式碼

那麼接下來就很簡單了,處理的時候,只要把原來的通知欄替換成集合根據taskId取到的例項即可。再則,必須把taskId一直傳到我們的ProgressResponseBody中,然後:

RxBus.getDefault().post(String.valueOf(taskId), new DownloadEvent(taskId, contentLength, bytesReaded));
複製程式碼

我們的事件DownloadEvent新增了新屬性taskId。

檔名

接下來我們說說關於fileName的坑點,為什麼會有坑點呢?我。。。為啥會問這樣的問題。。。很簡單啊,因為檔案覆蓋了唄,處理邏輯也很簡單,如果當前正常儲存檔名已經存在,那麼重新命名直至不存在,這樣無論單任務還是多工都會儲存相應的檔案,而不會覆蓋。當然了,更友好一點,提示使用者要不要重新下載啊?我們這裡就直接再下一個,就是這麼暴力。

那麼這裡的難點就是修改我們的fileName。最終程式碼:

@NonNull
private String getFileName(File saveFileDir, Response<ResponseBody> response) {
    ...
    if (TextUtils.isEmpty(contentDisposition)) {
        ...
    } else {
        ...
    }
    int count = 0;
    String temFileName = fileName;
    String fileNamePrefix;
    String fileNameSuffix;
    int pointIndex = fileName.lastIndexOf(".");
    if (pointIndex > -1) {
        fileNamePrefix = fileName.substring(0, pointIndex);
        fileNameSuffix = fileName.substring(pointIndex, fileName.length());
    } else {
        fileNamePrefix = fileName;
        fileNameSuffix = "";
    }
    do {
        File saveFile = new File(saveFileDir, temFileName);
        if (saveFile.exists()) {
            temFileName = String.format(Locale.getDefault(), "%s(%d)%s", fileNamePrefix, ++count, fileNameSuffix);
        } else {
            fileName = temFileName;
            break;
        }
    } while (true);
    return fileName;
}
複製程式碼

稍微有點小操作,模仿了下谷歌瀏覽器的下載命名規則,哈哈,後面加個(1)這樣子的,好像很多都是這樣子的,這個也很好實現,首先把以前的檔名分為字首和字尾,分隔符是"."。那麼只要在"."前面新增()就OK啦,其次判斷當前檔名是否存在,如果存在數字+1,否則就是我們最終的檔名。

那麼問題來啦,除了這兩個坑還有其它嗎?有,肯定有。例如回撥進度的時候不用每次都更新,可以隔一段時間或者說隔一定進度。

玩一玩Android下載框架

騷聊

整篇文章下來,發現實現一個下載框架也不過如此?是的,它真的不是很難,難點我覺得有兩個,一個是下載框架的視覺互動,這個好像等於沒說,哈哈;一個是相容性,好像這個也等於沒說;最後一個是高併發下載。逗我,你連最基礎的斷點續傳都沒講。。。確實沒講,但這個難嗎?當然了,自己並不是專門開發下載工具的,難點也只是自己猜的,勿怪。

還有一點就是很多人比較關心的,文中的程式碼在哪裡可以看到?這麼說吧,我發的教學型文章的程式碼90%來自自己的開源專案CrazyDaily,readme也列出了技術點,如果對哪一點比較感興趣,可以看看!特別重要的一點是有問題一定要說出來,不要害羞,無論是誰的問題。

當然這次千萬別問我為什麼掃的是知乎!!!

大家下次再見!

傳送門

Github:github.com/crazysunj/

部落格:crazysunj.com/

相關文章