玩一玩Android下載框架

前言

繼上篇《不同的HTTP緩存體驗》已經有一段時間了,一直沒寫教學型文章不是由於太忙,想了好久不知道以什麼爲主題,有個哥們看了個人開源項目CrazyDaily,好像對下載挺感興趣,那我就寫一篇吧!下載框架彷佛是咱們入門必學的一個技術點,由於它囊括了不少方面的知識,優秀的開源下載框架很是多,各有千秋。那麼,此刻,你們一塊兒跟着我來打造一款下載框架!準備好了嗎?android

效果

一向做風!No picture,say a J8!git

實戰

咱們從效果圖上簡單分析一下執行流程,首先打開二維碼,掃描一個下載連接;解析到下載連接跳轉下載中轉頁並彈出下載信息確認框;確認下載,通知欄回顯進度;下載完成,點擊可查看。github

OK,很實用的一個流程,掃碼和下載能夠算是咱們每天都會用到的兩個技術。web

掃碼

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

zxing的使用很簡單,這樣就調起掃碼界面了。記得請求攝像頭權限哦。緩存

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

ScannerActivity是咱們自定義的掃碼界面,支持從本地圖片中掃碼,實現過程忽略。掃完碼確定會回調一串字符串,例如這裏咱們是一個下載連接,那麼在哪裏回調呢?bash

@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頁。微信

中轉頁

那麼爲何要用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());
複製代碼

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

但你覺得這真的會彈出來來嗎?

測試中有些手機並不會彈出(並無回調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寫入咱們要保存的文件裏,沒什麼難點。這裏的難點實際上是如何獲得保存文件名,固然最簡單的就是用戶本身去設置。但咱們要的確定是本身動啊。

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還沒搞清楚。

擴展

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

多任務

第一個我留下的問題是,用戶屢次掃碼屢次建立下載任務會如何?咱們的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,不然就是咱們最終的文件名。

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

騷聊

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

還有一點就是不少人比較關心的,文中的代碼在哪裏能夠看到?這麼說吧,我發的教學型文章的代碼90%來自本身的開源項目CrazyDaily,readme也列出了技術點,若是對哪一點比較感興趣,能夠看看!特別重要的一點是有問題必定要說出來,不要害羞,不管是誰的問題。

固然此次千萬別問我爲何掃的是知乎!!!

你們下次再見!

傳送門

Github:github.com/crazysunj/

博客:crazysunj.com/

相關文章
相關標籤/搜索